Sommaire

BONJOUR
Musique "Invention BWV784" J.S. BACH 1685-1750

Tutoriel et Help

Assembleur NASM

Nous veillons aux respects des droits d'auteur : si par erreur un contenu protégé est disponible sans indication sur ce site, merci de contacter l'administrateur, il sera retiré ou mis à jour dans les plus brefs délais.


Langage Assembleur PC

Table des matières Plier Déplier

  • I. Préface
    • I-A. Objectif
    • I-B. Remerciements
    • I-C. Ressources sur Internet
    • I-D. Réactions
  • II. Introduction
    • II-A. Systèmes numériques
      • II-A-1. Décimal
      • II-A-2. Binaire
      • II-A-3. Hexadécimal
    • II-B. Organisation de l'ordinateur
      • II-B-1. Mémoire
      • II-B-2. Le CPU (processeur)
      • II-B-3. La famille des processeurs 80x86
      • II-B-4. Regitres 16 bits du 8086
      • II-B-5. Registres 32 bits du 80386
      • II-B-6. Mode réel
      • II-B-7. Mode protégé 16 bits
      • II-B-8. Mode protégé 32 bits
      • II-B-9. Interruptions
    • II-C. Langage assembleur
      • II-C-1. Langage machine
      • II-C-2. Langage d'assembleur
      • II-C-3. Opérandes d'instruction
      • II-C-4. Instructions de base
      • II-C-5. Directives
        • II-C-5-a. La directive equ
        • II-C-5-b. La directive ?ne
        • II-C-5-c. Directives de données
      • II-C-6. Entrées et sorties
      • II-C-7. Débogage
    • II-D. Créer un programme
      • II-D-1. Premier programme
      • II-D-2. Dépendance vis-à-vis du compilateur
      • II-D-3. Assembler le code
      • II-D-4. Compiler le code C
      • II-D-5. Lier les fichiers objet
      • II-D-6. Comprendre un listing assembleur
    • II-E. Fichier squelette
  • III. Bases du langage assembleur
    • III-A. Travailler avec les entiers
      • III-A-1. Représentation des entiers
        • III-A-1-a. Grandeur signée
        • III-A-1-b. Complément à 1
        • III-A-1-c. Complément à 2
      • III-A-2. Extension de signe
        • III-A-2-a. Réduire la taille des données
        • III-A-2-b. Augmenter la taille des données
        • III-A-2-c. Application à la programmation en C
      • III-A-3. Arithmétique en complément à 2
      • III-A-4. Programme exemple
      • III-A-5. Arithmétique en précision étendue
    • III-B. Structures de contrôle
      • III-B-1. Comparaison
      • III-B-2. Instructions de branchement
      • III-B-3. Les instructions de boucle
    • III-C. Traduire les structures de contrôle standard
      • III-C-1. Instructions if
      • III-C-2. Boucles while
      • III-C-3. Boucles do while
    • III-D. Exemple : trouver des nombres premiers
  • IV. Opérations sur les bits
    • IV-A. Opérations de décalage
      • IV-A-1. Décalages logiques
      • IV-A-2. Utilisation des décalages
      • IV-A-3. Décalages arithmétiques
      • IV-A-4. Décalages circulaires
      • IV-A-5. Application simple
    • IV-B. Opérations booléennes niveau bit
      • IV-B-1. L'opération ET
      • IV-B-2. L'opération OU
      • IV-B-3. L'opération XOR
      • IV-B-4. L'opération NOT
      • IV-B-5. L'instruction TEST
      • IV-B-6. Utilisation des opérations sur les bits
    • IV-C. Éviter les branchements conditionnels
    • IV-D. Manipuler les bits en C
      • IV-D-1. Les opérateurs niveau bit du C
      • IV-D-2. Utiliser les opérateurs niveau bit en C
    • IV-E. Représentations big et little endian
      • IV-E-1. Quand se soucier du caractère big ou little endian
    • IV-F. Compter les bits
      • IV-F-1. Méthode une
      • IV-F-2. Méthode deux
      • IV-F-3. Méthode trois
  • V. Sous-programmes
    • V-A. Adressage indirect
    • V-B. Exemple de sous-programme simple
    • V-C. La pile
    • V-D. Les Instructions CALL et RET
    • V-E. Conventions d'appel
      • V-E-1. Passer les paramètres via la pile
      • V-E-2. Variables locales sur la pile
    • V-F. Programme multimodule
    • V-G. Interfacer de l'assembleur avec du C
      • V-G-1. Sauvegarder les registres
      • V-G-2. Étiquettes de fonctions
      • V-G-3. Passer des paramètres
      • V-G-4. Calculer les adresses des variables locales
      • V-G-5. Retourner des valeurs
      • V-G-6. Autres conventions d'appel
      • V-G-7. Exemples
      • V-G-8. Appeler des fonctions C depuis l'assembleur
    • V-H. Sous-programmes réentrants et récursifs
      • V-H-1. Sous-programmes récursifs
      • V-H-2. Révision des types de stockage des variables en C
  • VI. Tableaux
    • VI-A. Introduction
      • VI-A-1. Définir des tableaux
        • VI-A-1-a. Définir des tableaux dans les segments data et bss
        • VI-A-1-b. Définir des tableaux comme variables locales
      • VI-A-2. Accéder aux éléments de tableaux
      • VI-A-3. Adressage indirect plus avancé
      • VI-A-4. Exemple
        • VI-A-4-a. L'instruction LEA revisitée
      • VI-A-5. Tableaux Multidimensionnels
        • VI-A-5-a. Tableaux à Deux Dimensions
        • VI-A-5-b. Dimensions supérieures à deux
        • VI-A-5-c. Passer des tableaux multidimensionnels comme paramètres en C
    • VI-B. Instructions de tableaux/chaînes
      • VI-B-1. Lire et écrire en mémoire
      • VI-B-2. Le préfixe d'instruction REP
      • VI-B-3. Instructions de comparaison de chaînes
      • VI-B-4. Les préfixes d'instruction REPx
      • VI-B-5. Exemple
  • VII. Virgule flottante
    • VII-A. Représentation en virgule flottante
      • VII-A-1. Nombres binaires non entiers
      • VII-A-2. Représentation en virgule flottante IEEE
        • VII-A-2-a. Simple précision IEEE
        • VII-A-2-b. Nombres dénormalisés
        • VII-A-2-c. Double précision IEEE
    • VII-B. Arithmétique en virgule flottante
      • VII-B-1. Addition
      • VII-B-2. Soustraction
      • VII-B-3. Multiplication et division
      • VII-B-4. Conséquences sur la programmation
    • VII-C. Le coprocesseur arithmétique
      • VII-C-1. Matériel
      • VII-C-2. Instructions
        • VII-C-2-a. Chargement et stockage
        • VII-C-2-b. Addition et soustraction
        • VII-C-2-c. Multiplication et division
        • VII-C-2-d. Comparaisons
        • VII-C-2-e. Instructions diverses
      • VII-C-3. Exemple : formule quadratique
      • VII-C-4. Exemple : lire un tableau depuis un fichier
      • VII-C-5. Exemple : rechercher les nombres premiers
  • VIII. Structures et C++
    • VIII-A. Structures
      • VIII-A-1. Introduction
      • VIII-A-2. Alignement en mémoire
      • VIII-A-3. Champs de bits
      • VIII-A-4. Utiliser des structures en assembleur
    • VIII-B. Assembleur et C++
      • VIII-B-1. Surcharge et décoration de noms
      • VIII-B-2. Références
      • VIII-B-3. Fonctions inline
      • VIII-B-4. Classes
        • VIII-B-4-a. Exemple
      • VIII-B-5. Héritage et polymorphisme
      • VIII-B-6. Autres fonctionnalités C++
  • IX. Annexe A : Instructions 80x86
    • IX-A. Instructions hors virgule flottante
    • IX-B. Instruction en virgule flottante

Copyright © 2001, 2002, 2004 by Paul Carter. Traduction par Sébastien Le Ray .

I. Préface

I-A. Objectif

L'objectif de ce livre est de permettre au lecteur de mieux comprendre comment les ordinateurs fonctionnent réellement à un niveau plus bas que les langages de programmation comme Pascal. En ayant une compréhension plus profonde de la façon dont fonctionnent les ordinateurs, le lecteur peu devenir plus productif dans le développement de logiciels dans des langages de plus haut niveau comme le C et le C++.

Apprendre à programmer en assembleur est un excellent moyen d'atteindre ce but. Les autres livres d'assembleur pour PC apprennent toujours à programmer le processeur 8086 qu'utilisait le PC originel de 1981 ! Le processeur 8086 ne supportait que le mode réel.

Dans ce mode, tout programme peu adresser n'importe quel endroit de la mémoire ou n'importe quel périphérique de l'ordinateur. Ce mode n'est pas utilisable pour un système d'exploitation sécurisé et multitâche. Ce livre parle plutôt de la façon de programmer les processeurs 80386 et plus récents en mode protégé (le mode dans lequel fonctionnent Windows et Linux). Ce mode supporte les fonctionnalités que les systèmes d'exploitation modernes offrent, comme la mémoire virtuelle et la protection mémoire. Il y a plusieurs raisons d'utiliser le mode protégé :

1. Il est plus facile de programmer en mode protégé qu'en mode réel 8086 que les autres livres utilisent ;

2. Tous les systèmes d'exploitation PC modernes fonctionnent en mode protégé ;

3. Il y a des logiciels libres disponibles qui fonctionnent dans ce mode.

Le manque de livres sur la programmation en assembleur PC en mode protégé est la principale raison qui a conduit l'auteur à écrire ce livre.

Comme nous y avons fait allusion ci-dessus, ce texte utilise des logiciels Libres/Open Source : l'assembleur NASM et le compilateur C/C++ DJGPP. Les deux sont disponibles en téléchargement sur Internet. Ce texte parle également de l'utilisation de code assembleur NASM sous Linux et avec les compilateurs C/C++ de Borland et Microsoft sous Windows. Les exemples pour toutes ces plateformes sont disponibles sur mon site Web : http://www.drpaulcarter.com/pcasm.

Vous devez télécharger le code exemple si vous voulez assembler et exécuter la plupart des exemples de ce tutoriel.

Soyez conscient que ce texte ne tente pas de couvrir tous les aspects de la programmation assembleur. L'auteur a essayé de couvrir les sujets les plus importants avec lesquels tous les programmeurs devraient être familiers.

I-B. Remerciements

L'auteur voudrait remercier les nombreux programmeurs qui ont contribué au mouvement Libre/Open Source. Tous les programmes et même ce livre lui-même ont été créés avec des logiciels gratuits. L'auteur voudrait remercier en particulier John S. Fine, Simon Tatham, Julian Hall et les autres développeurs de l'assembleur NASM sur lequel tous les exemples de ce livre sont basés ; DJ Delorie pour le développement du compilateur C/C++ DJGPP utilisé ; les nombreuses personnes qui ont contribué au compilateur GNU gcc sur lequel DJGPP est basé ; Donald Knuth et les autres pour avoir développé les langages composition TEX et LATEX2" qui ont été utilisés pour produire le livre ; Richard Stallman (fondateur de la Free Software Foundation), Linus Torvalds (créateur du noyau Linux) et les autres qui ont créé les logiciels sous-jacents utilisés pour ce travail.

Merci aux personnes suivantes pour leurs corrections :

John S. Fine ; Marcelo Henrique Pinto de Almeida ; Sam Hopkins ; Nick D'Imperio ; Jeremiah Lawrence ; Ed Beroset ; Jerry Gembarowski ; Ziqiang Peng ; Eno Compton ; Josh I Cates ; Mik Miin ; Luke Wallis ; Gaku Ueda ; Brian Heward ; Chad Gorshing ; F. Gotti ; Bob Wilkinson ; Markus Koegel ;Louis Taber.

I-C. Ressources sur Internet

Page de l'auteur : http://www.drpaulcarter.com/.

Page NASM sur SourceForge : http://sourceforge.net/projects/nasm/.

DJGPP : http://www.delorie.com/djgpp.

Assembleur Linux : http://www.linuxassembly.org/.

The Art of Assembly : http://webster.cs.ucr.edu/.

USENET comp.lang.asm.x86

Documentation Intel : http://developer.intel.com/design/Pentium4/documentation.htm.

I-D. Réactions

L'auteur accepte toute réaction sur ce travail.

E-mail : pacman128@gmail.com.

WWW : http://www.drpaulcarter.com/pcasm

II. Introduction

II-A. Systèmes numériques

La mémoire d'un ordinateur est constituée de nombres. Cette mémoire ne stocke pas ces nombres en décimal (base 10). Comme cela simplifie grandement le matériel, les ordinateurs stockent toutes les informations au format binaire (base 2). Tout d'abord, revoyons ensemble le système décimal.

II-A-1. Décimal

Les nombres en base 10 sont constitués de 10 chiffres possibles (0-9).

Chaque chiffre d'un nombre est associé à une puissance de 10 selon sa position dans le nombre. Par exemple :

234 = 2 x 102 + 3 × 101 + 4 × 100

II-A-2. Binaire

Les nombres en base 2 sont composés de deux chiffres possibles (0 et 1). Chaque chiffre est associé à une puissance de 2 selon sa position dans le nombre (un chiffre binaire isolé est appelé bit). Par exemple :

110012 = 1 × 24+ 1 × 23 + 0 × 22 + 0 × 21 + 1 × 20

= 16 + 8 + 1

= 25

Cet exemple montre comment passer du binaire au décimal. Le tableau 1.1 montre la représentation binaire des premiers nombres.

La figure 1.1 montre comment des chiffres binaires individuels (i.e., des bits) sont additionnés. Voici un exemple :

Image non disponible

Si l'on considère la division décimale suivante :

1234 ÷ 10 = 123 r 4

on peut voir que cette division sépare le chiffre le plus à droite du nombre et décale les autres chiffres d'une position vers la droite. Diviser par deux effectue une opération similaire, mais pour les chiffres binaires du nombre.

Considérons la division binaire suivante(1) :

11012 ÷ 102 = 1102 r 1

Cette propriété peut être utilisée pour convertir un nombre décimal en son équivalent binaire comme le montre la Figure 1.2. Cette méthode trouve le chiffre binaire le plus à droite en premier, ce chiffre est appelé le bit le moins signicatif (lsb, least signicant bit). Le chiffre le plus à gauche est appelé le bit le plus signicatif (msb, most signicant bit). L'unité de base de la mémoire consiste en un jeu de 8 bits appelé octet (byte).

Image non disponible

II-A-3. Hexadécimal

Les nombres hexadécimaux utilisent la base 16. L'hexadécimal (ou hexa en abrégé) peut être utilisé comme notation pour les nombres binaires. L'hexa a 16 chiffres possibles. Cela pose un problème, car il n'y a pas de symbole à utiliser pour les chiffres supplémentaires après 9. Par convention, on utilise des lettres pour les représenter. Les 16 chiffres de l'hexa sont 0-9 puis A, B, C, D, E et F. Le chiffre A équivaut à 10 en décimal, B à 11, etc. Chaque chiffre d'un nombre en hexa est associé à une puissance de 16. Par exemple :

2BD16 = 2 × 162 + 11 × 161 + 13 × 160

= 512 + 176 + 13

= 701

Pour convertir un nombre hexa en binaire, convertissez simplement chaque chiffre hexa en un nombre binaire de 4 bits. Par exemple, 24D16 est converti en 0010 0100 11012. Notez que les 0 de tête des 4 bits sont importants ! Si le 0 de tête pour le chiffre du milieu de 24D16 n'est pas utilisé, le résultat est faux. La conversion du binaire vers l'hexa est aussi simple. On effectue la même chose, dans l'autre sens. Convertissez chaque segment de 4 bits du binaire vers l'hexa. Commencez à droite, pas à gauche, du nombre binaire.

Cela permet de s'assurer que le procédé utilise les segments de 4 bits corrects(2)

Exemple :

Image non disponible

Un nombre de 4 bits est appelé quadruplet(nibble). Donc, chaque chiffre hexa correspond à un quadruplet. Deux quadruplets forment un octet et donc un octet peut être représenté par un nombre hexa à deux chiffres. La valeur d'un bit va de 0 à 11111111 en binaire, 0 à FF en hexa et 0 à 255 en décimal.

II-B. Organisation de l'ordinateur

II-B-1. Mémoire

La mémoire est mesurée en kilo-octets ( 210= 1024 octets), mega octets (220= 1 048 576 octets) et gigaoctets (230 = 1 073 741 824 octets).

L'unité mémoire de base est l'octet. Un ordinateur avec 32 mégaoctets de mémoire peut stocker jusqu'à environ 32 millions d'octets d'informations.

Chaque octet en mémoire est étiqueté par un nombre unique appelé son adresse comme le montre la Figure 1.4.

Image non disponible

Souvent, la mémoire est utilisée par bouts plus grands que des octets isolés. Sur l'architecture PC, on a donné des noms à ces portions de mémoire plus grandes, comme le montre le Tableau 1.2.

Image non disponible

Toutes les données en mémoire sont numériques. Les caractères sont stockés en utilisant un code caractère qui fait correspondre des nombres aux caractères. Un des codes caractère les plus connus est appelé ASCII (American Standard Code for Information Interchange, Code Américain Standard pour l'Échange d'Informations). Un nouveau code, plus complet, qui supplante l'ASCII est l'Unicode. Une des différences-clés entre les deux codes est que l'ASCII utilise un octet pour encoder un caractère alors que l'Unicode en utilise deux (ou un mot). Par exemple, l'ASCII fait correspondre l'octet 4116 (6510) au caractère majuscule A ; l'Unicode y fait correspondre le mot 004116. Comme l'ASCII n'utilise qu'un octet, il est limité à 256 caractères différents au maximum(3). L'Unicode étend les valeurs ASCII à des mots et permet de représenter beaucoup plus de caractères. C'est important afin de représenter les caractères de tous les langages du monde.

II-B-2. Le CPU (processeur)

Le processeur (CPU, Central Processing Unit) est le dispositif physique qui exécute les instructions. Les instructions que les processeurs peuvent exécuter sont généralement très simples. Elles peuvent nécessiter que les données sur lesquelles elles agissent soient présentes dans des emplacements de stockage spéciques dans le processeur lui-même appelés registres. Le processeur peut accéder aux données dans les registres plus rapidement qu'aux données en mémoire. Cependant, le nombre de registres d'un processeur est limité, donc le programmeur doit faire attention à n'y conserver que les données actuellement utilisées.

Les instructions que peut exécuter un type de processeur constituent le langage machine. Les programmes machine ont une structure beaucoup plus basique que les langages de plus haut niveau. Les instructions du langage machine sont encodées en nombres bruts, pas en format texte lisible. Un processeur doit être capable de décoder les instructions très rapidement pour fonctionner efficacement. Le langage machine est conçu avec ce but en tête, pas pour être facilement déchiffrable par les humains. Les programmes écrits dans d'autres langages doivent être convertis dans le langage machine programme qui traduit les programmes écrits dans un langage de programmation en langage machine d'une architecture d'ordinateur particulière. En général, chaque type de processeur a son propre langage machine unique. C'est une des raisons pour lesquelles un programme écrit pour Mac ne peut pas être exécuté sur un PC.

Les ordinateurs utilisent une horloge pour synchroniser l'exécution des instructions. L'horloge tourne à une fréquence fixée (appelée vitesse d'horloge). Lorsque vous achetez un ordinateur à 1,5GHz, 1,5GHz est la fréquence de cette horloge.

GHz signifie gigahertz ou un milliard de cycles par seconde. Un processeur à 1,5GHz a 1,5 milliard d'impulsions horloge par seconde.

L'horloge ne décompte pas les minutes et les secondes. Elle bat simplement à un rythme constant. Les composants électroniques du processeur utilisent les pulsations pour effectuer leurs opérations correctement, comme le battement d'un métronome aide à jouer de la musique à un rythme correct. Le nombre de battements (ou, comme on les appelle couramment cycles) que requiert une instruction dépend du modèle et de la génération du processeur. Le nombre de cycles dépend de l'instruction.

II-B-3. La famille des processeurs 80x86

Les PC contiennent un processeur de la famille des Intel 80x86 (ou un clone). Les processeurs de cette famille ont tous des fonctionnalités en commun, y compris un langage machine de base. Cependant, les membres les plus récents améliorent grandement les fonctionnalités.

8088,8086 : ces processeurs, du point de vue de la programmation sont identiques. Ils étaient les processeurs utilisés dans les tout premiers PC. Ils offrent plusieurs registres 16 bits : AX, BX, CX, DX, SI, DI, BP, SP, CS, DS, SS, ES, IP, FLAGS. Ils ne supportent que jusqu'à 1Mo de mémoire et n'opèrent qu'en mode réel. Dans ce mode, un programme peut accéder à n'importe quelle adresse mémoire, même la mémoire des autres programmes ! Cela rend le débogage et la sécurité très difficiles ! De plus, la mémoire du programme doit être divisée en segments. Chaque segment ne peut pas dépasser les 64 Ko.

80286 : ce processeur était utilisé dans les PC de type AT. Il apporte quelques nouvelles instructions au langage machine de base des 8088/86. Cependant, sa principale nouvelle fonctionnalité est le mode protégé 16 bits. Dans ce mode, il peut accéder jusqu'à 16 Mo de mémoire et empêcher les programmes d'accéder à la mémoire des uns et des autres. Cependant, les programmes sont toujours divisés en segments qui ne peuvent pas dépasser les 64 Ko.

80386 : ce processeur a grandement amélioré le 80286. Tout d'abord, il étend la plupart des registres à 32 bits (EAX, EBX, ECX, EDX, ESI, EDI, EBP, ESP, EIP) et ajoute deux nouveaux registres 16 bits : FS et GS.

Image non disponible

Il ajoute également un nouveau mode protégé 32 bits. Dans ce mode, il peut accéder jusqu'à 4 Go de mémoire. Les programmes sont encore divisés en segments, mais maintenant chaque segment peut également faire jusqu'à 4 Go !

80486/Pentium/Pentium Pro : ces membres de la famille 80x86 apportent très peu de nouvelles fonctionnalités. Ils accélèrent principalement l'exécution des instructions.

Pentium MMX : ce processeur ajoute les instructions MMX (Multi Media eXentions) au Pentium. Ces instructions peuvent accélérer des opérations graphiques courantes.

Pentium II : c'est un processeur Pentium Pro avec les instructions MMX (le Pentium III est grossièrement un Pentium II plus rapide).

II-B-4. Regitres 16 bits du 8086

Le processeur 8086 original fournissait quatre registres généraux de 16 bits : AX, BX, CX et DX. Chacun de ces registres peut être décomposé en deux registres de 8 bits. Par exemple, le registre AX pouvait être décomposé en AH et AL comme le montre la Figure 1.5. Le registre AH contient les 8 bits de poids fort de AX et AL contient les 8 bits de poids faible. Souvent, AH et AL sont utilisés comme des registres d'un octet indépendants ; cependant, il est important de réaliser qu'ils ne sont pas indépendants de AX. Changer la valeur de AX changera les valeurs de AL et BL et vice versa. Les registres généraux sont utilisés dans beaucoup de déplacements de données et instructions arithmétiques.

Il y a deux registres d'index de 16 bits : SI et DI. Ils sont souvent utilisés comme des pointeurs, mais peuvent être utilisés pour la plupart des mêmes choses que les registres généraux. Cependant, ils ne peuvent pas être décomposés en registres de 8 bits.

Les registres 16 bits BP et SP sont utilisés pour pointer sur des données dans la pile du langage machine et sont appelés le pointeur de base et le pointeur de pile, respectivement. Nous en reparlerons plus tard.

Les registres 16 bits CS, DS, SS et ES sont des registres de segment. Ils indiquent quelle zone de la mémoire est utilisée pour les différentes parties d'un programme. CS signifie Code Segment, DS Data Segment, SS Stack Segment (segment de pile) et ES Extra Segment. ES est utilisé en tant que registre de segment temporaire. Des détails sur ces registres se trouvent dans les sections II.B.6 et II.B.7.

Le registre de pointeur d'instruction (IP) est utilisé avec le registre CS pour mémoriser l'adresse de la prochaine instruction à exécuter par le processeur.

Normalement, lorsqu'une instruction est exécutée, IP est incrémenté pour pointer vers la prochaine instruction en mémoire.

Le registre FLAGS stocke des informations importantes sur les résultats d'une instruction précédente. Ces résultats sont stockés comme des bits individuels dans le registre. Par exemple, le bit Z est positionné à 1 si le résultat de l'instruction précédente était 0 ou à 0 sinon. Toutes les instructions ne modifient pas les bits dans FLAGS, consultez le tableau dans l'appendice pour voir comment chaque instruction affecte le registre FLAGS.

II-B-5. Registres 32 bits du 80386

Les processeurs 80386 et plus récents ont des registres étendus. Par exemple le registre AX 16 bits est étendu à 32 bits. Pour la compatibilité ascendante, AX fait toujours référence au registre 16 bits et on utilise EAX pour faire référence au registre 32 bits. AX représente les 16 bits de poids faible de EAX tout comme AL représente les 8 bits de poids faible de AX (et de EAX). Il n'y a pas moyen d'accéder aux 16 bits de poids fort de EAX directement. Les autres registres étendus sont EBX, ECX, EDX, ESI et EDI.

La plupart des autres registres sont également étendus. BP devient EBP ; SP devient ESP ; FLAGS devient EFLAGS et IP devient EIP. Cependant, contrairement aux registres généraux et d'index, en mode protégé 32 bits (dont nous parlons plus loin) seules les versions étendues de ces registres sont utilisées.

Les registres de segment sont toujours sur 16 bits dans le 80386. Il y a également deux registres de segment supplémentaires : FS et GS. Leurs noms n'ont pas de signification particulière. Ce sont des segments temporaires supplémentaires (comme ES).

Une des définitions du terme mot se réfère à la taille des registres de données du processeur. Pour la famille du 80x86, le terme est désormais un peu confus. Dans le Tableau 1.2, on voit que le terme mot est déni comme faisant 2 octets (ou 16 bits). Cette signification lui a été attribuée lorsque le premier 8086 est apparu. Lorsque le 80386 a été développé, il a été décidé de laisser la définition de mot inchangée, même si la taille des registres avait changé.

II-B-6. Mode réel

En mode réel, la mémoire est limitée à seulement un mégaoctet (220 octets).

Alors d'où vient l'infâme limite des 640 Ko du DOS ? Le BIOS requiert une partie des 1 Mo pour son propre code et pour les périphériques matériels comme l'écran.

Les adresses valides vont de 00000 à FFFFF (en hexa). Ces adresses nécessitent un nombre sur 20 bits. Cependant, un nombre de 20 bits ne tiendrait dans aucun des registres 16 bits du 8086. Intel a résolu le problème, en utilisant deux valeurs de 16 bits pour déterminer une adresse. La première valeur de 16 bits est appelée le sélecteur. Les valeurs du sélecteur doivent être stockées dans des registres de segment. La seconde valeur de 16 bits est appelée le déplacement (offset). L'adresse physique identifiée par un couple sélecteur/déplacement 32 bits est calculée par la formule

16 * sélecteur + déplacement

Multiplier par 16 en hexa est facile, il suffit d'ajouter un 0 à la droite du nombre. Par exemple, l'adresse physique référencée par 047C:0048 est obtenue de la façon suivante :

047C0

+0048

04808

De fait, la valeur du sélecteur est un numéro de paragraphe (voir Tableau 1.2).

Les adresses réelles segmentées ont des inconvénients :

  • une seule valeur de sélecteur peut seulement référencer 64Ko de mémoire (la limite supérieure d'un déplacement de 16 bits). Que se passe-t-il si un programme a plus de 64 Ko de code ? Une seule valeur de CS ne peut pas être utilisée pour toute l'exécution du programme. Le programme doit être divisé en sections (appelées segments) de moins de 64 Ko. Lorsque l'exécution passe d'un segment à l'autre, la valeur de CS doit être changée. Des problèmes similaires surviennent avec de grandes quantités de données et le registre DS. Cela peut être très gênant ! ;
  • chaque octet en mémoire n'a pas une adresse segmentée unique. L'adresse physique 04808 peut être référencée par 047C:0048, 047D:0038, 047E:0028 ou 047B:0058. Cela complique la comparaison d'adresses segmentées.

II-B-7. Mode protégé 16 bits

Dans le mode protégé 16 bits du 80286, les valeurs du sélecteur sont interprétées de façon totalement différente par rapport au mode réel. En mode réel, la valeur d'un sélecteur est un numéro de paragraphe en mémoire.

En mode protégé, un sélecteur est un indice dans un tableau de descripteurs. Dans les deux modes, les programmes sont divisés en segments. En mode réel, ces segments sont à des positions fixes en mémoire et le sélecteur indique le numéro de paragraphe auquel commence le segment. En mode protégé, les segments ne sont pas à des positions fixes en mémoire physique. De fait, ils n'ont même pas besoin d'être en mémoire du tout !

Le mode protégé utilise une technique appelée mémoire virtuelle. L'idée de base d'un système de mémoire virtuelle est de ne garder en mémoire que les programmes et les données actuellement utilisés. Le reste des données et du code sont stockés temporairement sur le disque jusqu'à ce qu'on ait à nouveau besoin d'eux. Dans le mode protégé 16 bits, les segments sont déplacés entre la mémoire et le disque selon les besoins. Lorsqu'un segment est rechargé en mémoire depuis le disque, il est très probable qu'il sera à un endroit en mémoire différent de celui où il était avant d'être placé sur le disque.

Tout ceci est effectué de façon transparente par le système d'exploitation. Le programme n'a pas à être écrit différemment pour que la mémoire virtuelle fonctionne.

En mode protégé, chaque segment est assigné à une entrée dans un tableau de descripteurs. Cette entrée contient toutes les informations dont le système a besoin à propos du segment. Ces informations indiquent : s'il est actuellement en mémoire ; s'il est en mémoire, où il se trouve ; les droits d'accès (p.e., lecture seule). L'indice de l'entrée du segment est la valeur du sélecteur stockée dans les registres de segment.

Un journaliste PC bien connu a baptisé le processeur 286 « cerveau mort » (brain dead).

Un gros inconvénient du mode protégé 16 bits est que les déplacements sont toujours des quantités sur 16 bits. En conséquence, les tailles de segment sont toujours limitées au plus à 64 Ko. Cela rend l'utilisation de grands tableaux problématique.

II-B-8. Mode protégé 32 bits

Le 80386 a introduit le mode protégé 32 bits. Il y a deux différences majeures entre les modes protégés 32 bits du 386 et 16 bits du 286 :

1. Les déplacements sont étendus à 32 bits. Cela permet à un déplacement d'aller jusqu'à 4 milliards. Ainsi, les segments peuvent avoir des tailles jusqu'à 4 Go ;

2. Les segments peuvent être divisés en unités plus petites de 4 Ko appelées pages. Le système de mémoire virtuelle fonctionne maintenant avec des pages plutôt qu'avec des segments. Cela implique que seules certaines parties d'un segment peuvent être présentes en mémoire à un instant donné. En mode 16 bits du 286, soit le segment en entier est en mémoire, soit rien n'y est. Ce qui n'aurait pas été pratique avec les segments plus grands que permet le mode 32 bits.

Dans Windows 3.x, le mode standard fait référence au mode protégé 16 bits du 286 et le mode amélioré (enhanced) fait référence au mode 32 bits.

Windows 9X, Windows NT/2000/XP, OS/2 et Linux fonctionnent tous en mode protégé 32 bits paginé.

II-B-9. Interruptions

Quelques fois, le flot ordinaire d'un programme doit être interrompu pour traiter des évènements qui requièrent une réponse rapide. Le matériel d'un ordinateur offre un mécanisme appelé interruptions pour gérer ces évènements.

Par exemple, lorsqu'une souris est déplacée, la souris interrompt le programme en cours pour gérer le déplacement de la souris (pour déplacer le curseur, etc.). Les interruptions provoquent le passage du contrôle à un gestionnaire d'interruptions. Les gestionnaires d'interruptions sont des routines qui traitent une interruption. Chaque type d'interruption est assignée à un nombre entier. Au début de la mémoire physique, réside un tableau de vecteurs d'interruptions qui contient les adresses segmentées des gestionnaires d'interruptions. Le numéro d'une interruption est essentiellement un indice dans ce tableau.

Les interruptions externes proviennent de l'extérieur du processeur (la souris est un exemple de ce type). Beaucoup de périphériques d'E/S soulèvent des interruptions (p.e., le clavier, le timer, les lecteurs de disque, le CD-ROM et les cartes son). Les interruptions internes sont soulevées depuis le processeur, à cause d'une erreur ou d'une instruction d'interruption.

Les interruptions erreur sont également appelées traps. Les interruptions générées par l'instruction d'interruption sont également appelées interruptions logicielles. Le DOS utilise ce type d'interruption pour implémenter son API (Application Programming Interface). Les systèmes d'exploitation plus récents (comme Windows et Unix) utilisent une interface basée sur C(4).

Beaucoup de gestionnaires d'interruptions redonnent le contrôle au programme interrompu lorsqu'ils se terminent. Ils restaurent tous les registres aux valeurs qu'ils avaient avant l'interruption. Ainsi, le programme interrompu s'exécute comme si rien n'était arrivé (excepté qu'il perd quelques cycles processeur). Les traps ne reviennent généralement jamais. Souvent, elles arrêtent le programme.

II-C. Langage assembleur

II-C-1. Langage machine

Chaque type de processeur comprend son propre langage machine. Les instructions dans le langage machine sont des nombres stockés sous la forme d'octets en mémoire. Chaque instruction a son propre code numérique unique appelé code d'opération ou opcode (operation code) en raccourci. Les instructions des processeurs 80x86 varient en taille. L'opcode est toujours au début de l'instruction. Beaucoup d'instructions comprennent également les données (p.e. des constantes ou des adresses) utilisées par l'instruction.

Le langage machine est très difficile à programmer directement. Déchiffrer la signification d'instructions codées numériquement est fatigant pour des humains. Par exemple, l'instruction qui dit d'ajouter les registres EAX et EBX et de stocker le résultat dans EAX est encodée par les codes hexadécimaux suivants :

03 C3

C'est très peu clair. Heureusement, un programme appelé un assembleur peut faire ce travail laborieux à la place du programmeur.

II-C-2. Langage d'assembleur

Un programme en langage d'assembleur est stocké sous la forme de texte (comme un programme dans un langage de plus haut niveau). Chaque instruction assembleur représente exactement une instruction machine. Par exemple, l'instruction d'addition décrite ci-dessus serait représentée en langage assembleur comme suit :

add eax, ebx

Ici, la signification de l'instruction est beaucoup plus claire qu'en code machine.

Le mot add est un mnémonique pour l'instruction d'addition. La forme générale d'une instruction assembleur est :

mnémonique opérande(s)

Un assembleur est un programme qui lit un fichier texte avec des instructions assembleur et convertit l'assembleur en code machine. Les compilateurs sont des programmes qui font des conversions similaires pour les langages de programmation de haut niveau. Un assembleur est beaucoup plus simple qu'un compilateur.

Cela a pris plusieurs années aux scientifiques de l'informatique pour concevoir le simple fait d'écrire un compilateur !

Chaque instruction du langage d'assembleur représente directement une instruction machine. Les instructions d'un langage de plus haut niveau sont beaucoup plus complexes et peuvent requérir beaucoup d'instructions machine.

Une autre différence importante entre l'assembleur et les langages de haut niveau est que comme chaque type de processeur a son propre langage machine, il a également son propre langage d'assemblage. Porter des programmes assembleur entre différentes architectures d'ordinateur est beaucoup plus difficile qu'avec un langage de haut niveau.

Les exemples de ce livre utilisent le Netwide Assembler ou NASM en

raccourci. Il est disponible gratuitement sur Internet (voyez la préface pour l'URL). Des assembleurs plus courants sont l'Assembleur de Microsoft (MASM) ou l'Assembleur de Borland (TASM). Il y a quelques différences de syntaxe entre MASM/TASM et NASM.

II-C-3. Opérandes d'instruction

Les instructions en code machine ont un nombre et un type variables d'opérandes ; cependant, en général, chaque instruction a un nombre fixé d'opérandes (0 à 3). Les opérandes peuvent avoir les types suivants :

registre : ces opérandes font directement référence au contenu des registres du processeur ;

mémoire : ils font référence aux données en mémoire. L'adresse de la donnée peut être une constante codée en dur dans l'instruction ou calculée en utilisant les valeurs des registres. Les adresses sont toujours des déplacements relatifs au début d'un segment ;

immédiat : ce sont des valeurs fixes qui sont listées dans l'instruction elle-même. Elles sont stockées dans l'instruction (dans le segment de code), pas dans le segment de données ;

implicite : ces opérandes ne sont pas entrés explicitement. Par exemple, l'instruction d'incrémentation ajoute 1 à un registre ou à la mémoire. Le 1 est implicite.

II-C-4. Instructions de base

L'instruction la plus basique est l'instruction MOV. Elle déplace les données d'un endroit à un autre (comme l'opérateur d'assignement dans un langage de haut niveau). Elle prend deux opérandes :

Sélectionnez
mov dest, src

La donnée spécifiée par src est copiée vers dest. Une restriction est que les deux opérandes ne peuvent pas être tous deux des opérandes mémoire.

Cela nous montre un autre caprice de l'assembleur. Il y a souvent des règles quelque peu arbitraires sur la façon dont les différentes instructions sont utilisées. Les opérandes doivent également avoir la même taille. La valeur de AX ne peut pas être stockée dans BL.

Voici un exemple (les points-virgules marquent un commentaire) :

Sélectionnez
mov eax, 3 ; stocke 3 dans le registre EAX (3 est un operande immediat)

mov bx, ax ; stocke la valeur de AX dans le registre BX

L'instruction ADD est utilisée pour additionner des entiers.

Sélectionnez
add eax, 4 ; eax = eax + 4

add al, ah ; al = al + ah

L'instruction SUB soustrait des entiers.

Sélectionnez
sub bx, 10 ; bx = bx - 10

sub ebx, edi ; ebx = ebx - edi

Les instructions INC et DEC incrémentent ou décrémentent les valeurs de 1. Comme le 1 est un opérande implicite, le code machine pour INC et DEC est plus petit que celui des instructions ADD et SUB équivalentes.

Sélectionnez
inc ecx ; ecx++

dec dl ; dl--

II-C-5. Directives

Une directive est destinée à l'assembleur, pas au processeur. Les directives sont généralement utilisées pour indiquer à l'assembleur de faire quelque chose ou pour l'informer de quelque chose. Elles ne sont pas traduites en code machine. Les utilisations courantes des directives sont :

  • la définition de constantes ;
  • la définition de mémoire pour stocker des données ;
  • grouper la mémoire en segment ;
  • inclure des codes source de façon conditionnelle ;
  • inclure d'autres fichiers.

Le code NASM est analysé par un préprocesseur, exactement comme en C. Il y a beaucoup de commandes identiques à celles du préprocesseur C.

Cependant, les directives du préprocesseur NASM commencent par un % au lieu d'un # comme en C.

II-C-5-a. La directive equ

La directive equ peut être utilisée pour définir un symbole. Les symboles sont des constantes nommées qui peuvent être utilisées dans le programme assembleur. Le format est le suivant :

Sélectionnez
symbole equ valeur

Les valeurs des symboles ne peuvent pas être redéfinies plus tard.

II-C-5-b. La directive ?ne

Cette directive est semblable à la directive #define du C. Elle est le plus souvent utilisée pour définir des macros, exactement comme en C.

Sélectionnez
?fine SIZE 100
mov eax, SIZE
Image non disponible

Le code ci-dessus définit une macro appelée SIZE et montre son utilisation dans une instruction MOV. Les macros sont plus flexibles que les symboles de deux façons. Elles peuvent être redéfinies et peuvent être plus que de simples nombres constants.

II-C-5-c. Directives de données

Les directives de données sont utilisées dans les segments de données pour réserver de la place en mémoire. Il y a deux façons de réserver de la mémoire. La première ne fait qu'allouer la place pour les données ; a seconde alloue la place et donne une valeur initiale. La première méthode utilise une des directives RESX. Le X est remplacé par une lettre qui détermine la taille de l'objet (ou des objets) qui sera stocké. Le Tableau 1.3 montre les valeurs possibles.

La seconde méthode (qui définit une valeur initiale) utilise une des directives DX. Les lettres X sont les mêmes que celles de la directive RESX.

Il est très courant de marquer les emplacements mémoire avec des labels (étiquettes). Les labels permettent de faire référence facilement aux emplacements mémoire dans le code. Voici quelques exemples :

Sélectionnez
L1 db 0 ; octet libelle L1 avec une valeur initiale de 0
L2 dw 1000 ; mot labelle L2 avec une valeur initiale de 1000
L3 db 110101b ; octet initialise a la valeur binaire 110101 (53 en decimal)
L4 db 12h ; octet initialise a la valeur hexa 12 (18 en decimal)
L5 db 17o ; octet initialise a la valeur octale 17 (15 en decimal)
L6 dd 1A92h ; double mot initialise a la valeur hexa 1A92
L7 resb 1 ; 1 octet non initialise
L8 db "A" ; octet initialise avec le code ASCII du A (65)

Les doubles et simples quotes sont traitées de la même façon. Les définitions de données consécutives sont stockées séquentiellement en mémoire. C'est-à-dire que le mot L2 est stocké immédiatement après L1 en mémoire. Des séquences de mémoire peuvent également être définies.

Sélectionnez
L9 db 0, 1, 2, 3 ; definit 4 octets
L10 db "w", "o", "r", 'd', 0 ; definit une chaine C = "word"
L11 db 'word', 0 ; idem L10

La directive DD peut être utilisée pour définir à la fois des entiers et des constantes à virgule flottante en simple précision(5). Cependant, la directive DQ ne peut être utilisée que pour définir des constantes à virgule flottante en double précision.

Pour les grandes séquences, la directive de NASM TIMES est souvent utile. Cette directive répète son opérande un certain nombre de fois. Par exemple :

Sélectionnez
L12 times 100 db 0 ; equivalent a 100 (db 0)
L13 resw 100 ; reserve de la place pour 100 mots

Souvenez-vous que les labels peuvent être utilisés pour faire référence à des données dans le code. Il y a deux façons d'utiliser les labels. Si un label simple est utilisé, il fait référence à l'adresse (ou offset) de la donnée.

Si le label est placé entre crochets ([ ]), il est interprété comme la donnée à cette adresse. En d'autres termes, il faut considérer un label comme un pointeur vers la donnée et les crochets déréférencent le pointeur, exactement comme l'astérisque en C (MASM/TASM utilisent une convention différente).

En mode 32 bits, les adresses sont sur 32 bits. Voici quelques exemples :

Sélectionnez
  1. mov al, [L1] ; Copie l'octet situe en L1 dans AL
  2. mov eax, L1 ; EAX = addresse de l'octet en L1
  3. mov [L1], ah ; copie AH dans l'octet en L1
  4.  mov eax, [L6] ; copie le double mot en L6 dans EAX
  5.  add eax, [L6] ; EAX = EAX + double mot en L6
  6. add [L6], eax ; double mot en L6 += EAX
  7. mov al, [L6] ; copie le premier octet du double mot en L6 dans AL

La ligne 7 de cet exemple montre une propriété importante de NASM. L'assembleur ne garde pas de trace du type de données auquel se réfère le label. C'est au programmeur de s'assurer qu'il (ou elle) utilise un label correctement.

Plus tard, il sera courant de stocker des adresses dans les registres et utiliser les registres comme un pointeur en C. Là encore, aucune vérification n'est faite pour savoir si le pointeur est utilisé correctement. À ce niveau, l'assembleur est beaucoup plus sujet à erreur que le C.

Considérons l'instruction suivante :

Sélectionnez
  1. mov [L6], 1 ; stocke 1 en L6

Cette instruction produit une erreur operation size not specified. Pourquoi ?

Parce que l'assembleur ne sait pas s'il doit considérer 1 comme un octet, un mot ou un double mot. Pour réparer cela, il faut ajouter un spécificateur de taille :

Sélectionnez
  1. mov dword [L6], 1 ; stocke 1 en L6

Cela indique à l'assembleur de stocker un 1 dans le double mot qui commence en L6. Les autres spécificateurs de taille sont : BYTE, WORD, QWORD et TWORD(6).

II-C-6. Entrées et sorties

Les entrées et sorties sont des activités très dépendantes du système. Cela implique un interfaçage avec le matériel. Les langages de plus haut niveau, comme C, fournissent des bibliothèques standard de routines pour une interface de programmation simple, uniforme pour les E/S. Les langages d'assembleur n'ont pas de bibliothèque standard. Ils doivent soit accéder directement au matériel (avec une opération privilégiée en mode protégé) ou utiliser les routines de bas niveau éventuellement fournies par le système d'exploitation.

Il est très courant pour les routines assembleur d'être interfacées avec du C. Un des avantages de cela est que le code assembleur peut utiliser les routines d'E/S de la bibliothèque standard du C. Cependant, il faut connaître les règles de passage des informations aux routines que le C utilise.

Ces règles sont trop compliquées pour en parler ici (nous en parlerons plus tard !). Pour simplifier les E/S, l'auteur a développé ses propres routines qui masquent les règles complexes du C et fournissent une interface beaucoup plus simple. Le Tableau 1.4 décrit les routines fournies. Toutes les routines préservent les valeurs de tous les registres, excepté les routines read. Ces routines modifient la valeur du registre EAX. Pour utiliser ces routines, il faut inclure un fichier contenant les informations dont l'assembleur a besoin pour les utiliser. Pour inclure un fichier dans NASM, utilisez la directive du préprocesseur %include. La ligne suivante inclut le fichier requis par les routines d'E/S de l'auteur(7) :

Sélectionnez
  1. %include "asm_io.inc"

Pour utiliser une de ces routines d'affichage, il faut charger EAX avec la valeur correcte et utiliser une instruction CALL pour l'invoquer. L'instruction CALL est équivalente à un appel de fonction dans un langage de haut niveau.

Elle saute à une autre portion de code, mais revient à son origine une fois la routine terminée. Le programme d'exemple ci-dessous montre plusieurs exemples d'appel à ces routines d'E/S.

Image non disponible

II-C-7. Débogage

La bibliothèque de l'auteur contient également quelques routines utiles pour déboguer les programmes. Ces routines de débogage affichent des informations sur l'état de l'ordinateur sans le modifier. Ces routines sont en fait des macros qui sauvegardent l'état courant du processeur puis font appel à une sous-routine. Les macros sont définies dans le fichier asm_io.inc dont nous avons parlé plus haut. Les macros sont utilisées comme des instructions ordinaires. Les opérandes des macros sont séparés par des virgules.

Il y a quatre routines de débogage nommées dump_regs, dump_mem, dump_stack et dump_math ; elles affichent respectivement les valeurs des registres, de la mémoire, de la pile et du coprocesseur arithmétique.

dump_regs : cette macro affiche les valeurs des registres (en hexadécimal) de l'ordinateur sur stdout (i.e. l'écran). Elle affiche également les bits positionnés dans le registre FLAGS(8). Par exemple, si le drapeau zéro (zero ag) est à 1, ZF est affiché. S'il est à 0, il n'est pas affiché. Elle prend en argument un entier qui est affiché également. Cela peut aider à distinguer la sortie de différentes commandes dump_regs.

dump_mem : cette macro affiche les valeurs d'une région de la mémoire (en hexadécimal et également en caractères ASCII). Elle prend trois arguments séparés par des virgules. Le premier est un entier utilisé pour étiqueter la sortie (comme l'argument de dump_regs). Le second argument est l'adresse à afficher (cela peut être un label). Le dernier argument est le nombre de paragraphes de 16 octets à afficher après l'adresse. La mémoire affichée commencera au premier multiple de paragraphe avant l'adresse demandée.

dump_stack : cette macro affiche les valeurs de la pile du processeur (la pile sera présentée dans le Chapitre V). La pile est organisée en doubles-mots et cette routine les affiche de cette façon. Elle prend trois arguments délimités par des virgules. Le premier est un entier (comme pour

dump_regs). Le second est le nombre de doubles-mots à afficher après l'adresse que le registre EBP contient et le troisième argument est le nombre de doubles-mots à afficher avant l'adresse contenue dans EBP.

dump_math : cette macro affiche les valeurs des registres du coprocesseur arithmétique. Elle ne prend qu'un entier en argument qui est utilisé pour étiqueter la sortie comme le fait l'argument de dump_regs.

II-D. Créer un programme

Aujourd'hui, il est très peu courant de créer un programme autonome écrit complètement en langage assembleur. L'assembleur est habituellement utilisé pour optimiser certaines routines critiques. Pourquoi ? Il est beaucoup plus simple de programmer dans un langage de plus haut niveau qu'en assembleur.

De plus, utiliser l'assembleur rend le programme très dur à porter sur d'autres plateformes. En fait, il est rare d'utiliser l'assembleur tout court.

Alors, pourquoi apprendre l'assembleur ?

1. Quelques fois, le code écrit en assembleur peut être plus rapide et plus compact que le code généré par un compilateur.

2. L'assembleur permet l'accès à des fonctionnalités matérielles du système directement qu'il pourrait être difficile ou impossible à utiliser depuis un langage de plus haut niveau.

3. Apprendre à programmer en assembleur aide à acquérir une compréhension plus profonde de la façon dont fonctionne un ordinateur.

4. Apprendre à programmer en assembleur aide à mieux comprendre comment les compilateurs et les langages de haut niveau comme C fonctionnent.

Ces deux derniers points démontrent qu'apprendre l'assembleur peut être utile même si on ne programme jamais dans ce langage plus tard. En fait, l'auteur programme rarement en assembleur, mais il utilise les leçons qu'il en a tirées tous les jours.

II-D-1. Premier programme

Les programmes qui suivront dans ce texte partiront tous du programme de lancement en C de la Figure 1.6.

Image non disponible

Il appelle simplement une autre fonction nommée asm_main. C'est la routine qui sera écrite en assembler proprement dit. Il y a plusieurs avantages à utiliser un programme de lancement en C. Tout d'abord, cela laisse le système du C initialiser le programme de façon à fonctionner correctement en mode protégé. Tous les segments et les registres correspondants seront initialisés par le C. L'assembleur n'aura pas à se préoccuper de cela. Ensuite, la bibliothèque du C pourra être utilisée par le code assembleur. Les routines d'E/S de l'auteur en tirent parti. Elles utilisent les fonctions d'E/S du C (printf, etc.). L'exemple suivant montre un programme assembleur simple.

first.asm
Sélectionnez
asm
1 ; fichier : first.asm
2 ; Premier programme assembleur. Ce programme attend la saisie de deux
3 ; entiers et affiche leur somme.
4 ;
5 ; Pour creer l'executable en utilisant djgpp :
6 ; nasm -f coff first.asm
7 ; gcc -o first first.o driver.c asm_io.o
8
9 %include "asm_io.inc"
10 ;
11 ; Les donnees initialisees sont placees dans le segment .data
12 ;
13 segment .data
14 ;
15 ; Ces labels referencent les chaines utilisees pour l'affichage
16 ;
17 prompt1 db "Entrez un nombre : ", 0 ; N'oubliez pas le 0 final
18 prompt2 db "Entrez un autre nombre : ", 0
19 outmsg1 db "Vous avez entre ", 0
20 outmsg2 db " et ", 0
21 outmsg3 db ", leur somme vaut ", 0
22
23 ;
24 ; Les donnees non initialisees sont placees dans le segment .bss
25 ;
26 segment .bss
27 ;
28 ; Ces labels referencent les doubles-mots utilises pour stocker les entrees
29 ;
30 input1 resd 1
31 input2 resd 1
32
33 ;
34 ; Le code est place dans le segment .text
35 ;
36 segment .text
37 global _asm_main
38 _asm_main :
39 enter 0,0 ; initialisation
40 pusha
41
42 mov eax, prompt1 ; affiche un message
43 call print_string
44
45 call read_int ; lit un entier
46 mov [input1], eax ; le stocke dans input1
47
48 mov eax, prompt2 ; affiche un message
49 call print_string
50
51 call read_int ; lit un entier
52 mov [input2], eax ; le stocke dans input2
53
54 mov eax, [input1] ; eax = dword en input1
55 add eax, [input2] ; eax += dword en input2
56 mov ebx, eax ; ebx = eax
57
58 dump_regs 1 ; affiche les valeurs des registres
59 dump_mem 2, outmsg1, 1 ; affiche le contenu de la memoire
60 ;
61 ; Ce qui suit affiche le message resultat en plusieurs etapes
62 ;
63 mov eax, outmsg1
64 call print_string ; affiche le premier message
65 mov eax, [input1]
66 call print_int ; affiche input1
67 mov eax, outmsg2
68 call print_string ; affiche le second message
69 mov eax, [input2]
70 call print_int ; affiche input2
71 mov eax, outmsg3
72 call print_string ; affiche le troisieme message
73 mov eax, ebx
74 call print_int ; affiche la somme (ebx)
75 call print_nl ; affiche une nouvelle ligne
76
77 popa
78 mov eax, 0 ; retourne dans le programme C
79 leave
80 ret

La ligne 13 du programme définit une section qui spécifie la mémoire à stocker dans le segment de données (dont le nom est .data). Seules les données initialisées doivent être définies dans ce segment.

Dans les lignes 17 à 21, plusieurs chaînes sont déclarées. Elles seront affichées avec la bibliothèque C et doivent donc se terminer par un caractère null (code ASCII 0). Souvenez-vous qu'il y a une grande différence entre 0 et '0'.

Les données non initialisées doivent être déclarées dans le segment bss (appelé .bss à la ligne 26). Ce segment tient son nom d'un vieil opérateur assembleur UNIX qui signifiait block started by symbol. Il y a également un segment de pile. Nous en parlerons plus tard.

Le segment de code est appelé .text historiquement. C'est là que les instructions sont placées. Notez que le label de code pour la routine main (ligne 38) a un préfixe de soulignement. Cela fait partie de la convention d'appel C. Cette convention spécifie les règles que le C utilise lorsqu'il compile le code. Il est très important de connaître cette convention lorsque l'on interface du C et de l'assembleur. Plus loin, la convention sera présentée dans son intégralité ; cependant, pour l'instant, il suffit de savoir que tous les symboles C (i.e., les fonctions et les variables globales) ont un préfixe de soulignement qui leur est ajouté par le compilateur C (cette règle s'applique spécifiquement pour DOS/Windows, le compilateur C Linux n'ajoute rien du tout aux noms des symboles).

La directive global ligne 37 indique à l'assembleur de rendre le label _asm_main global. Contrairement au C, les labels ont une portée interne par défaut. Cela signifie que seul le code du même module peut utiliser le label.

La directive global donne au(x) label(s) spécifié(s) une portée externe. Ce type de label peut être accédé par n'importe quel module du programme.

Le module asm_io déclare les labels print_int, et.al. comme étant globaux. C'est pourquoi l'on peut les utiliser dans le module first.asm.

II-D-2. Dépendance vis-à-vis du compilateur

Le code assembleur ci-dessus est spécifique au compilateur C/C++ GNU(9) gratuit DJGPP(10).

Ce compilateur peut être téléchargé gratuitement depuis Internet. Il nécessite un PC à base de 386 ou plus et tourne sous DOS, Windows 95/98 ou NT. Ce compilateur utilise des fichiers objet au format COFF (Common Object File Format). Pour assembler le fichier au format COFF, utilisez l'option -f coff avec nasm (comme l'indiquent les commentaires du code). L'extension du fichier objet sera o.

Le compilateur C Linux est également un compilateur GNU. Pour convertir le code ci-dessus afin qu'il tourne sous Linux, retirez simplement les préfixes de soulignement aux lignes 37 et 38. Linux utilise le format ELF (Executable and Linkable Format) pour les fichiers objet. Utilisez l'option -f elf pour Linux. Il produit également un fichier objet avec l'extension o.

Les fichiers spécifiques aucompilateur, disponibles sur le site de l'auteur, ont déjà été modifiés pour fonctionner avec le compilateur approprié.

Borland C/C++ est un autre compilateur populaire. Il utilise le format Microsoft OMF pour les fichiers objets. Utilisez l'option -f obj pour les compilateurs Borland. L'extension du fichier objet sera obj. Le format OMF utilise des directives segment différentes de celles des autres formats objet.

Le segment data (ligne 13) doit être changé en :

Sélectionnez
segment _DATA public align=4 class=DATA use32

Le segment bss (ligne 26) doit être changé en :

Sélectionnez
segment _BSS public align=4 class=BSS use32

Le segment text (ligne 36) doit être changé en :

Sélectionnez
segment _TEXT public align=1 class=CODE use32

De plus, une nouvelle ligne doit être ajoutée avant la ligne 36 :

Sélectionnez
group DGROUP _BSS _DATA

Le compilateur Microsoft C/C++ peut utiliser soit le format OMF, soit le format Win32 pour les fichiers objet (si on lui passe un format OMF, il convertit les informations au format Win32 en interne). Le format Win32 permet de définir les segments comme pour DJGPP et Linux. Utilisez l'option -f win32 pour produire un fichier objet dans ce format. L'extension du fichier objet sera obj.

II-D-3. Assembler le code

La première étape consiste à assembler le code. Depuis la ligne de commande, saisissez :

Sélectionnez
nasm -f format-objet first.asm

où format-objet est soit co , soit elf , soit obj soit win32 selon le compilateur C utilisé (souvenez-vous que le fichier source doit être modifié pour Linux et pour Borland).

II-D-4. Compiler le code C

Compilez le fichier driver.c en utilisant un compilateur C. Pour DJGPP, utilisez :

Sélectionnez
gcc -c driver.c

L'option -c signifie de compiler uniquement, sans essayer de lier. Cette option fonctionne à la fois sur les compilateurs Linux, Borland et Microsoft.

II-D-5. Lier les fichiers objet

L'édition de liens est le procédé qui consiste à combiner le code machine et les données des fichiers objet et des bibliothèques afin de créer un fichier exécutable. Comme nous allons le voir, ce processus est compliqué.

Le code C nécessite la bibliothèque standard du C et un code de démarrage spécial afin de s'exécuter. Il est beaucoup plus simple de laisser le compilateur C appeler l'éditeur de liens avec les paramètres corrects que d'essayer d'appeler l'éditeur de liens directement. Par exemple, pour lier le code du premier programme en utilisant DJGPP, utilisez :

Sélectionnez
gcc -o first driver.o first.o asm_io.o

Cela crée un exécutable appelé first.exe (ou juste first sous Linux).

Avec Borland, on utiliserait :

Sélectionnez
bcc32 first.obj driver.obj asm_io.obj

Borland utilise le nom du premier fichier afin de déterminer le nom de l'exécutable. Donc, dans le cas ci-dessus, le programme s'appellera first.exe.

Il est possible de combiner les étapes de compilation et d'édition de liens.

Par exemple :

Sélectionnez
gcc -o first driver.c first.o asm_io.o

Maintenant gcc compilera driver.c puis liera.

II-D-6. Comprendre un listing assembleur

L'option -l fichier-listing peut être utilisée pour indiquer à nasm de créer un fichier listing avec le nom donné. Ce fichier montre comment le code a été assemblé. Voici comment les lignes 17 et 18 (dans le segment data) apparaissent dans le fichier listing (les numéros de ligne sont dans le

fichier listing ; cependant, notez que les numéros de ligne dans le fichier source peuvent ne pas être les mêmes).

Sélectionnez
48 00000000 456E7465722061206E- prompt1 db "Entrez un nombre : ", 0
49 00000009 756D6265723A2000
50 00000011 456E74657220616E6F- prompt2 db "Entrez un autre nombre : ", 0
51 0000001A 74686572206E756D62-
52 00000023 65723A2000

Les nombres diffèrent sur la version française, car ce ne sont pas les mêmes caractères.

La première colonne de chaque ligne est le numéro de la ligne et la seconde est le déplacement (en hexa) de la donnée dans le segment. La troisième colonne montre les données hexa brutes qui seront stockées. Dans ce cas, les données hexa correspondent aux codes ASCII. Enfin, le texte du fichier source est affiché sur la droite. Les déplacements listés dans la seconde colonne ne sont très probablement pas les déplacements réels auxquels les données seront placées dans le programme terminé. Chaque module peut définir ses propres labels dans le segment de données (et les autres segments également). Lors de l'étape d'édition de liens (voir Section II.D.5), toutes ces définitions de labels de segment de données sont combinées pour ne former qu'un segment de données. Les déplacements finals sont alors calculés par l'éditeur de liens.

Voici une petite portion (lignes 54 à 56 du fichier source) du segment text dans le fichier listing :

Sélectionnez
94 0000002C A1[00000000] mov eax, [input1]
95 00000031 0305[04000000] add eax, [input2]
96 00000037 89C3 mov ebx, eax

La troisième colonne montre le code machine généré par l'assembleur. Souvent le code complet pour une instruction ne peut pas encore être calculé.

Par exemple, ligne 94, le déplacement (ou l'adresse) de input1 n'est pas connu avant que le code ne soit lié. L'assembleur peut calculer l'opcode pour l'instruction mov (qui, d'après le listing, est A1), mais il écrit le déplacement entre crochets, car la valeur exacte ne peut pas encore être calculée. Dans ce cas, un déplacement temporaire de 0 est utilisé, car input1 est au début du segment bss déclaré dans ce fichier. Souvenez-vous que cela ne signifie pas qu'il sera au début du segment bss final du programme. Lorsque le code est lié, l'éditeur de liens insérera le déplacement correct à la place.

D'autres instructions, comme ligne 96, ne font référence à aucun label. Dans ce cas, l'assembleur peut calculer le code machine complet.

Représentations Big et Little Endian

Endian est prononcé comme indian.

Si l'on regarde attentivement la ligne 95, quelque chose semble très bizarre à propos du déplacement entre crochets dans le code machine. Le label input2 est au déplacement 4 (comme défini dans ce fichier) ; cependant, le déplacement qui apparaît en mémoire n'est pas 00000004, mais 04000000.

Pourquoi ? Des processeurs différents stockent les entiers multioctets dans des ordres différents en mémoire. Il y a deux méthodes populaires pour stocker : big endian et little endian. Le big endian est la méthode qui semble la plus naturelle. L'octet le plus fort (i.e. le plus significatif) est stocké en premier, puis le second plus fort, etc. Par exemple, le dword 00000004 serait stocké sous la forme des quatre octets suivants : 00 00 00 04.

Les mainframes IBM, la plupart des processeurs RISC et les processeurs Motorola utilisent tous cette méthode big endian. Cependant, les processeurs de type Intel utilisent la méthode little endian ! Ici, l'octet le moins significatif est stocké en premier. Donc, 00000004 est stocké en mémoire sous la forme 04 00 00 00. Ce format est codé en dur dans le processeur et ne peut pas être changé. Normalement, le programmeur n'a pas besoin de s'inquiéter du format utilisé. Cependant, il y a des circonstances où c'est important.

1. Lorsque des données binaires sont transférées entre différents ordinateurs (soit via des fichiers, soit via un réseau).

2. Lorsque des données binaires sont écrites en mémoire comme un entier multioctet puis relues comme des octets individuels ou vice versa.

Le caractère big ou little endian ne s'applique pas à l'ordre des éléments d'un tableau. Le premier élément d'un tableau est toujours à l'adresse la plus petite. C'est également valable pour les chaînes (qui sont juste des tableaux de caractères). Cependant, le caractère big ou little endian s'applique toujours aux éléments individuels des tableaux.

II-E. Fichier squelette

La Figure 1.7 montre un fichier squelette qui peut être utilisé comme point de départ pour l'écriture de programmes assembleur.

Image non disponible

III. Bases du langage assembleur

III-A. Travailler avec les entiers

III-A-1. Représentation des entiers

Les entiers se décomposent en deux catégories : signés et non signés. Les entiers non signés (qui sont positifs) sont représentés d'une manière binaire très intuitive. Le nombre 200 en tant qu'entier non signé sur un octet serait représenté par 11001000 (ou C8 en hexa).

Les entiers signés (qui peuvent être positifs ou négatifs) sont représentés d'une façon plus compliquée. Par exemple, considérons −56. +56 serait représenté par l'octet 00111000. Sur papier, on peut représenter −56 comme −111000, mais comment cela serait-il représenté dans un octet en mémoire de l'ordinateur. Comment serait stocké le signe moins ?

Il y a trois techniques principales qui ont été utilisées pour représenter les entiers signés dans la mémoire de l'ordinateur. Toutes ces méthodes utilisent le bit le plus significatif de l'entier comme bit de signe. Ce bit vaut 0 si le nombre est positif et 1 s'il est négatif.

III-A-1-a. Grandeur signée

La première méthode est la plus simple, elle est appelée grandeur signée.

Elle représente l'entier en deux parties. La première partie est le bit de signe et la seconde est la grandeur entière. Donc 56 serait représenté par l'octet 00111000 (le bit de signe est souligné) et −56 serait 10111000. La valeur d'octet la plus grande serait 01111111 soit +127 et la plus petite valeur sur un octet serait 11111111 soit −127. Pour obtenir l'opposé d'une valeur, on inverse le bit de signe. Cette méthode est intuitive, mais a ses inconvénients. Tout d'abord, il y a deux valeurs possibles pour 0 : +0 (00000000) et −0 (10000000). Comme zéro n'est ni positif ni négatif, ces deux représentations devraient se comporter de la même façon. Cela complique la logique de l'arithmétique pour le processeur. De plus, l'arithmétique générale est également compliquée. Si l'on ajoute 10 à −56, cela doit être transformé en 10 moins 56. Là encore, cela complique la logique du processeur.

III-A-1-b. Complément à 1

La seconde méthode est appelée représentation en complément à un.

Le complément à un d'un nombre est trouvé en inversant chaque bit du nombre (une autre façon de l'obtenir est que la valeur du nouveau bit est 1 − anciennevaleurdubit). Par exemple, le complément à un de 00111000 (+56) est 11000111. Dans la notation en complément à un, calculer le complément à un est équivalent à la négation. Donc 11000111 est la représentation de −56. Notez que le bit de signe a été automatiquement changé par le complément à un et que, comme l'on s'y attendait, appliquer le complément à un deux fois redonne le nombre de départ. Comme pour la première méthode, il y a deux représentations pour 0 : 00000000 (+0) et 11111111 (−0).

L'arithmétique des nombres en complément à 1 est compliquée. Voici une astuce utile pour trouver le complément à 1 d'un nombre en hexadécimal sans repasser en binaire. L'astuce est d'ôter le chiffre hexa de F (ou 15 en décimal). Cette méthode suppose que le nombre de bits dans le nombre est multiple de 4. Voici un exemple : +56 est représenté par 38 en hexa. Pour trouver le complément à 1, ôtez chaque chiffre de F pour obtenir C7 en hexa. Cela concorde avec le résultat ci-dessus.

III-A-1-c. Complément à 2

Les deux premières méthodes décrites ont été utilisées sur les premiers ordinateurs. Les ordinateurs modernes utilisent une troisième méthode appelée représentation en complément à 2. On trouve le complément à 2 d'un nombre en effectuant les deux opérations suivantes :

1. Trouver le complément à un du nombre ;

2. Ajouter 1 au résultat de l'étape 1.

Voici un exemple en utilisant 00111000 (56). Tout d'abord, on calcule le complément à 1 : 11000111. Puis, on ajoute 1 :

11000111

+ 1

11001000

Dans la notation en complément à 2, calculer le complément à 2 est équivalent à trouver l'opposé d'un nombre. Donc, 11001000 est la représentation en complément à 2 de −56. Deux négations devraient reproduire le nombre original. Curieusement le complément à 2 ne remplit pas cette condition. Prenez le complément à 2 de 11001000 en ajoutant 1 au complément à 1.

00110111

+ 1

00111000

Lorsque l'on effectue l'addition dans l'opération de complémentation à 2, l'addition du bit le plus à gauche peut produire une retenue. Cette retenue n'est pas utilisée. Souvenez-vous que toutes les données sur un ordinateur sont d'une taille fixe (en termes de nombre de bits). Ajouter deux octets produit toujours un résultat sur un octet (comme ajouter deux mots donne un mot, etc.) Cette propriété est importante pour la notation en complément à 2. Par exemple, considérons zéro comme un nombre en complément à 2 sur un octet (00000000). Calculer son complément à 2 produit la somme :

11111111

+ 1

c 00000000

où c représente une retenue (plus tard, nous montrerons comment détecter cette retenue, mais elle n'est pas stockée dans le résultat). Donc, en notation en complément à 2, il n'y a qu'un zéro. Cela rend l'arithmétique en complément à 2 plus simple que les méthodes précédentes.

En utilisant la notation en complément à 2, un octet signé peut être utilisé pour représenter les nombres −128 à +127.

Image non disponible

Le Tableau 2.1 montre quelques valeurs choisies. Si 16 bits sont utilisés, les nombres signés −32 768 à +32 767 peuvent être représentés. +32 767 est représenté par 7FFF, −32 768

par 8000, -128 par FF80 et -1 par FFFF. Les nombres en complément à 2 sur 32 bits vont de −2 milliards à +2 milliards environ.

Le processeur n'a aucune idée de ce qu'un octet en particulier (ou un mot ou un double-mot) est supposé représenté. L'assembleur n'a pas le concept de types qu'un langage de plus haut niveau peut avoir. La façon dont les données sont interprétées dépend de l'instruction dans laquelle on les utilise. Que la valeur FF soit considérée comme représentant un −1 signé ou 1 +255 non signé dépend du programmeur. Le langage C définit des types entiers signés et non signés. Cela permet au compilateur C de déterminer les instructions correctes à utiliser avec les données.

III-A-2. Extension de signe

En assembleur, toutes les données ont une taille bien spécifiée. Il n'est pas rare de devoir changer la taille d'une donnée pour l'utiliser avec d'autres. Réduire la taille est le plus simple.

III-A-2-a. Réduire la taille des données

Pour réduire la taille d'une donnée, il suffit d'en retirer les bits les plus significatifs. Voici un exemple trivial :

Sélectionnez
mov ax, 0034h ; ax = 52 (stocké sur 16 bits)
mov cl, al ; cl = les 8 bits de poids faible de ax

Bien sûr, si le nombre ne peut pas être représenté correctement dans une plus petite taille, réduire la taille ne fonctionne pas. Par exemple, si AX valait 0134h (ou 308 en décimal) alors le code ci-dessus mettrait quand même CL à 34h. Cette méthode fonctionne à la fois avec les nombres signés et non signés. Considérons les nombres signés, si AX valait FFFFh (−1 sur un mot), alors CL vaudrait FFh (−1 sur un octet). Cependant, notez que si la valeur dans AX était signée, elle aurait été tronquée et le résultat aurait été faux !

La règle pour les nombres non signés est que tous les bits retirés soient à 0 afin que la conversion soit correcte. La règle pour les nombres signés est que les bits retirés doivent soit tous être des 1 soit tous des 0. De plus, le premier bit à ne pas être retiré doit valoir la même chose que ceux qui l'ont été. Ce bit sera le bit de signe pour la valeur plus petite. Il est important qu'il ait la même valeur que le bit de signe original !

III-A-2-b. Augmenter la taille des données

Augmenter la taille des données est plus compliqué que la réduire. Considérons l'octet hexa FF. S'il est étendu à un mot, quelle valeur aura-t-il ? Cela dépend de la façon dont FF est interprété. Si FF est un octet non signé (255 en décimal), alors le mot doit être 00FF ; cependant, s'il s'agit d'un octet signé (−1 en décimal), alors le mot doit être FFFF.

En général, pour étendre un nombre non signé, on met tous les bits supplémentaires du nouveau nombre à 0. Donc, FF devient 00FF. Cependant, pour étendre un nombre signé, on doit étendre le bit de signe. Cela signifie que les nouveaux bits deviennent des copies du bit de signe. Comme le bit de signe de FF est à 1, les nouveaux bits doivent également être des 1, pour donner FFFF. Si le nombre signé 5A (90 en décimal) était étendu, le résultat serait 005A.

Il y a plusieurs instructions fournies par le 80386 pour étendre les nombres.

Souvenez-vous que l'ordinateur ne sait pas si un nombre est signé ou non. C'est au programmeur d'utiliser l'instruction adéquate. Pour les nombres non signés, on peut placer des 0 dans les bits de poids fort de façon simple en utilisant l'instruction MOV. Par exemple pour étendre l'octet de AL en un mot non signé dans AX :

Sélectionnez
mov ah, 0 ; met les 8 bits de poids fort à 0

Cependant, il n'est pas possible d'utiliser une instruction MOV pour convertir le mot non signé de AX en un double-mot non signé dans EAX. Pourquoi pas ? Il n'y a aucune façon d'accéder aux 16 bits de poids fort de EAX dans un MOV. Le 80386 résout ce problème en fournissant une nouvelle instruction : MOVZX. Cette instruction a deux opérandes. La destination (premier opérande) doit être un registre de 16 ou 32 bits. La source (deuxième opérande) doit être un registre 8 ou 16 bits. L'autre restriction est que la destination doit être plus grande que la source (la plupart des instructions requièrent que la source soit de la même taille que la destination). Voici quelques exemples :

Sélectionnez
movzx eax, ax ; étend ax à eax

movzx eax, al ; étend al à eax

movzx ax, al ; étend al à ax

movzx ebx, ax ; étend ax à ebx

Pour les nombres signés, il n'y a pas de façon simple d'utiliser l'instruction MOV quel que soit le cas. Le 8086 fournit plusieurs instructions pour étendre les nombres signés. L'instruction CBW (Convert Byte to Word, Convertir un Octet en Mot) étend le signe du registre AL dans AX. Les opérandes sont implicites. L'instruction CWD (Convert Word to Double word, Convertir un Mot en Double-mot) étend le signe de AX dans DX:AX. La notation DX:AX signifie qu'il faut considérer les registres DX et AX comme un seul registre 32 bits avec les 16 bits de poids forts dans DX et les bits de poids faible dans AX (souvenez-vous que le 8086 n'avait pas de registre 32 bits du tout !). Le 80386 a apporté plusieurs nouvelles instructions. L'instruction CWDE (Convert Word to Double word Extended, convertir un mot en double mot étendue) étend le signe de AX dans EAX. L'instruction CDQ (Convert Double word to Quad word, Convertir un Double-mot en Quadruple-mot) étend le signe de EAX dans EDX:EAX (64 bits !) Enn, l'instruction MOVSX fonctionne comme MOVZX excepté qu'elle utilise les règles des nombres signés.

III-A-2-c. Application à la programmation en C

L'extension d'entiers signés et non signés a également lieu en C. Les variables en C peuvent être déclarées soit comme signées soit comme non signées (les int sont signés).

Image non disponible

Le C ANSI ne définit pas si le type char est signé ou non, c'est à chaque compilateur de le décider. C'est pourquoi on définit le type explicitement dans la Figure 2.1.

Considérons le code de la Figure 2.1. À la ligne 3, la variable a est étendue en utilisant les règles pour les valeurs non signées (avec MOVZX), mais à la ligne 4, les règles signées sont utilisées pour b (avec MOVSX).

Il existe un bogue de programmation courant en C qui est directement lié à notre sujet. Considérons le code de la Figure 2.2. Le prototype de fgetc()est :

Sélectionnez
int fgetc( FILE * );

On peut se demander pourquoi la fonction renvoie un int puisqu'elle lit des caractères. La raison est qu'elle renvoie normalement un char (étendu à une valeur int en utilisant l'extension de zéro). Cependant, il y a une valeur qu'elle peut retourner qui n'est pas un caractère, EOF. C'est une macro habituellement définie comme valant −1. Donc, fgetc() retourne soit un char étendu à une valeur int (qui ressemble à 000000xx en hexa) soit EOF (qui ressemble à FFFFFFFF en hexa).

Le problème avec le programme de la Figure 2.2 est que fgetc() renvoie un int, mais cette valeur est stockée dans un char. Le C tronquera alors les bits de poids fort pour faire tenir la valeur de l'int dans le char. Le seul problème est que les nombres (en hexa) 000000FF et FFFFFFFF seront tous deux tronqués pour donner l'octet FF. Donc, la boucle while ne peut pas distinguer la lecture de l'octet FF dans le fichier et la fin de ce fichier.

Ce que fait exactement le code dans ce cas change selon que le char est signé ou non. Pourquoi ? Parce que ligne 2, ch est comparé avec EOF. Comme EOF est un int(11), ch sera étendu à un int afin que les deux valeurs comparées soient de la même taille(12). Comme la Figure 2.1 le montrait, le fait qu'une variable soit signée ou non est très important.

Si le char n'est pas signé, FF est étendu et donne 000000FF. Cette valeur est comparée à EOF (FFFFFFFF) et est différente. Donc la boucle ne finit jamais !

Si le char est signé, FF est étendu et donne FFFFFFFF. Les deux valeurs sont alors égales et la boucle se termine. Cependant, comme l'octet FF peut avoir été lu depuis le fichier, la boucle peut se terminer prématurément.

La solution à ce problème est de définir la variable ch comme un int, pas un char. Dans ce cas, aucune troncature ou extension n'est effectuée à la ligne 2. Dans la boucle, il est sans danger de tronquer la valeur puisque ch doit alors être réellement un octet.

III-A-3. Arithmétique en complément à 2

Comme nous l'avons vu plus tôt, l'instruction add effectue une addition et l'instruction sub effectue une soustraction. Deux des bits du registre EFLAGS que ces instructions positionnent sont overow (dépassement de capacité) et carry ag (retenue). Le drapeau d'overow est à 1 si le résultat réel de l'opération est trop grand et ne tient pas dans la destination pour l'arithmétique signée. Le drapeau de retenue est à 1 s'il y a une retenue dans le bit le plus significatif d'une addition d'une soustraction. Donc, il peut être utilisé pour détecter un dépassement de capacité en arithmétique non signée. L'utilisation du drapeau de retenue pour l'arithmétique signée va être vue sous peu. Un des grands avantages du complément à 2 est que les règles pour l'addition et la soustraction sont exactement les mêmes que pour les entiers non signés. Donc, add et sub peuvent être utilisées pour les entiers signés ou non.

002C 44

+ FFFF + (−1)

002B 43

Il y a une retenue de générée, mais elle ne fait pas partie de la réponse.

Il y a deux instructions de multiplication et de division différentes. Tout d'abord, pour multiplier, utilisez les instructions MUL ou IMUL. L'instruction MUL est utilisée pour multiplier les nombres non signés et IMUL est utilisée pour multiplier les entiers signés. Pourquoi deux instructions différentes sont nécessaires ? Les règles pour la multiplication sont différentes pour les nombres non signés et les nombres signés en complément à 2. Pourquoi cela ?

Image non disponible

Considérons la multiplication de l'octet FF avec lui-même donnant un résultat sur un mot. En utilisant la multiplication non signée, il s'agit de 255 fois 255 soit 65 025 (ou FE01 en hexa). En utilisant la multiplication signée, il s'agit de −1 fois −1 soit 1 (ou 0001 en hexa).

Il y a plusieurs formes pour les instructions de multiplication. La plus ancienne ressemble à cela :

Sélectionnez
mul source

La source est soit un registre soit une référence mémoire. Cela ne peut pas être une valeur immédiate. Le type exact de la multiplication dépend de la taille de l'opérande source. Si l'opérande est un octet, elle est multipliée par l'octet situé dans le registre AL et le résultat est stocké dans les 16 bits de AX. Si la source fait 16 bits, elle est multipliée par le mot situé dans AX et le résultat 32 bits est stocké dans DX:AX. Si la source fait 32 bits, elle est multipliée par EAX et le résultat 64 bits est stocké dans EDX:EAX.

L'instruction IMUL a les mêmes formats que MUL, mais ajoute également d'autres formats d'instruction. Il y a des formats à deux et à trois opérandes :

Sélectionnez
imul dest, source1

imul dest, source1, source2

Le Tableau 2.2 montre les combinaisons possibles.

Les deux opérateurs de division sont DIV et IDIV. Ils effectuent respectivement une division entière non signée et une division entière signée. Le format général est :

Sélectionnez
div source

Si la source est sur 8 bits, alors AX est divisé par l'opérande. Le quotient est stocké dans AL et le reste dans AH. Si la source est sur 16 bits, alors DX:AX est divisé par l'opérande. Le quotient est stocké dans AX et le reste dans DX. Si la source est sur 32 bits, alors EDX:EAX est divisé par l'opérande, le quotient est stocké dans EAX et le reste dans EDX. L'instruction IDIV fonctionne de la même façon. Il n'y a pas d'instructions IDIV spéciales comme pour IMUL. Si le quotient est trop grand pour tenir dans son registre ou que le diviseur vaut 0, le programme et interrompu et se termine. Une erreur courante est d'oublier d'initialiser DX ou EDX avant la division.

L'instruction NEG inverse son opérande en calculant son complément à 2. L'opérande peut être n'importe quel registre ou emplacement mémoire sur 8 bits, 16 bits, or 32 bits.

III-A-4. Programme exemple

math.asm
Sélectionnez
1 %include "asm_io.inc"
2 segment .data ; Chaines affichees
3 prompt db "Entrez un nombre : ", 0
4 square_msg db "Le carre de l'entree vaut ", 0
5 cube_msg db "Le cube de l'entree vaut ", 0
6 cube25_msg db "Le cube de l'entree fois 25 vaut ", 0
7 quot_msg db "Le quotient de cube/100 vaut ", 0
8 rem_msg db "Le reste de cube/100 vaut ", 0
9 neg_msg db "La négation du reste vaut ", 0
10
11 segment .bss
12 input resd 1
13
14 segment .text
15 global _asm_main
16 _asm_main :
17 enter 0,0 ; routine d'initialisation
18 pusha
19
20 mov eax, prompt
21 call print_string
22
23 call read_int
24 mov [input], eax
25
26 imul eax ; edx:eax = eax * eax
27 mov ebx, eax ; sauvegarde le résultat dans ebx
28 mov eax, square_msg
29 call print_string
30 mov eax, ebx
31 call print_int
32 call print_nl
33
34 mov ebx, eax
35 imul ebx, [input] ; ebx *= [entree]
36 mov eax, cube_msg
37 call print_string
38 mov eax, ebx
39 call print_int
40 call print_nl
41
42 imul ecx, ebx, 25 ; ecx = ebx*25
43 mov eax, cube25_msg
44 call print_string
45 mov eax, ecx
46 call print_int
47 call print_nl
48
49 mov eax, ebx
50 cdq ; initialise edx avec extension de signe
51 mov ecx, 100 ; on ne peut pas diviser par une valeur immédiate
52 idiv ecx ; edx:eax / ecx
53 mov ecx, eax ; sauvegarde le résultat dans ecx
54 mov eax, quot_msg
55 call print_string
56 mov eax, ecx
57 call print_int
58 call print_nl
59 mov eax, rem_msg
60 call print_string
61 mov eax, edx
62 call print_int
63 call print_nl
64
65 neg edx ; inverse le reste
66 mov eax, neg_msg
67 call print_string
68 mov eax, edx
69 call print_int
70 call print_nl
71
72 popa
73 mov eax, 0 ; retour au C
74 leave
75 ret

III-A-5. Arithmétique en précision étendue

Le langage assembleur fournit également des instructions qui permettent d'effectuer des additions et des soustractions sur des nombres plus grands que des doubles mots. Ces instructions utilisent le drapeau de retenue. Comme nous l'avons dit plus haut, les instructions ADD et SUB modifient le drapeau de retenue si une retenue est générée. Cette information stockée dans le drapeau de retenue peut être utilisée pour additionner ou soustraire de grands nombres en morcelant l'opération en doubles-mots (ou plus petit).

Les instructions ADC et SBB utilisent les informations données par le drapeau de retenue. L'instruction ADC effectue l'opération suivante :

opérande1 = opérande1 + drapeau de retenue + opérande2

L'instruction SBB effectue :

opérande1 = opérande1 - drapeau de retenue - opérande2

Comment sont-elles utilisées ? Considérons la somme d'entiers 64 bits dans EDX:EAX et EBX:ECX. Le code suivant stockerait la somme dans EDX:EAX :

Sélectionnez
1 add eax, ecx ; additionne les 32 bits de poids faible

2 adc edx, ebx ; additionne les 32 bits de poids fort et la retenue

La soustraction est très similaire. Le code suivant soustrait EBX:ECX de EDX:EAX :

Sélectionnez
1 sub eax, ecx ; soustrait les 32 bits de poids faible

2 sbb edx, ebx ; soustrait les 32 bits de poids fort et la retenue

Pour les nombres vraiment grands, une boucle peut être utilisée (voir Section III.B). Pour une boucle de somme, il serait pratique d'utiliser l'instruction ADC pour toutes les itérations (au lieu de toutes sauf la première).

Cela peut être fait en utilisant l'instruction CLC (CLear Carry) juste avant que la boucle ne commence pour initialiser le drapeau de retenue à 0. Si le drapeau de retenue vaut 0, il n'y a pas de différence entre les instructions ADD et ADC. La même idée peut être utilisée pour la soustraction.

III-B. Structures de contrôle

Les langages de haut niveau fournissent des structures de contrôle de haut niveau (p.e., les instructions if et while) qui contrôlent le flot d'exécution. Le langage assembleur ne fournit pas de structures de contrôle aussi complexes. Il utilise à la place l'infâme goto et l'utiliser de manière inappropriée peut conduire à du code spaghetti ! Cependant, il est possible d'écrire des programmes structurés en assembleur. La procédure de base est de concevoir la logique du programme en utilisant les structures de contrôle de haut niveau habituelles et de traduire cette conception en langage assembleur (comme le ferait un compilateur).

III-B-1. Comparaison

Les structures de contrôle décident ce qu'il faut faire en comparant des données. En assembleur, le résultat d'une comparaison est stocké dans le registre FLAGS pour être utilisé plus tard. Le 80x86 fournit l'instruction CMP pour effectuer des comparaisons. Le registre FLAGS est positionné selon la différence entre les deux opérandes de l'instruction CMP. Les opérandes sont soustraits et le registre FLAGS est positionné selon le résultat, mais ce résultat n'est stocké nulle part. Si vous avez besoin du résultat, utilisez l'instruction SUB au lieu de CMP.

Pour les entiers non signés, il y a deux drapeaux (bits dans le registre FLAGS) qui sont importants : les drapeaux zéro (ZF) et retenue (CF). Le drapeau zero est allumé (1) si le résultat de la différence serait 0. Considérons une comparaison comme :

Sélectionnez
cmp vleft, vright

La différence vleft - vright est calculée et les drapeaux sont positionnés en fonction. Si la différence calculée par CMP vaut 0, vleft = vright, alors ZF est allumé(i.e. 1) et CF est éteint (i.e. 0). Si vleft > vright, ZF est éteint et CF également. Si vleft < vright, alors ZF est éteint et CF est allumé (retenue).

Pour les entiers signés, il y a trois drapeaux importants : le drapeau zéro (ZF), le drapeau d'overow (OF) et le drapeau de signe (SF). Le drapeau d'overow est allumé si le résultat d'une opération dépasse la capacité (de trop ou de trop peu). Le drapeau de signe est allumé si le résultat d'une opération est négatif. Si vleft = vright, ZF est allumé (comme pour les entiers non signés). Si vleft > vright, ZF est éteint et SF = OF. Si vleft < vright, ZF est éteint et SF 6= OF.

N'oubliez pas que d'autres instructions peuvent aussi changer le registre FLAGS, pas seulement CMP.

Pourquoi SF = OF si vleft > vright ? S'il n'y
a pas d'overow, alors la différence aura la valeur correcte et doit être positive ou nulle. Donc, SF = OF = 0. Par contre, s'il y a un overow, la différence n'aura pas la valeur correcte (et sera en fait négative).
Donc, SF = OF = 1.

III-B-2. Instructions de branchement

Les instructions de branchement peuvent transférer l'exécution à n'importe quel point du programme. En d'autres termes, elles agissent comme un goto. Il y a deux types de branchements : conditionnel et inconditionnel. Un branchement inconditionnel est exactement comme un goto, il effectue toujours le branchement. Un branchement conditionnel peut effectuer le branchement ou non selon les drapeaux du registre FLAGS. Si un branchement conditionnel n'effectue pas le branchement, le contrôle passe à l'instruction suivante.

L'instruction JMP (abréviation de jump) effectue les branchements inconditionnels.

Son seul argument est habituellement l'étiquette de code de l'instruction à laquelle se brancher. L'assembleur ou l'éditeur de liens remplacera l'étiquette par l'adresse correcte de l'instruction. C'est une autre des opérations pénibles que l'assembleur fait pour rendre la vie du programmeur plus simple. Il est important de réaliser que l'instruction immédiatement après l'instruction JMP ne sera jamais exécutée à moins qu'une autre instruction ne se branche dessus !

Il y a plusieurs variantes de l'instruction de saut :

SHORT ce saut est très limité en portée. Il ne peut bouger que de plus ou moins 128 octets en mémoire. L'avantage de ce type de saut est qu'il utilise moins de mémoire que les autres. Il utilise un seul octet signé pour stocker le déplacement du saut. Le déplacement est le nombre d'octets à sauter vers l'avant ou vers l'arrière (le déplacement est ajouté à EIP). Pour spécifier un saut court, utilisez le mot-clé SHORT immédiatement avant l'étiquette dans l'instruction JMP ;

NEAR ce saut est le saut par défaut, que ce soit pour les branchements inconditionnels ou conditionnels, il peut être utilisé pour sauter vers n'importe quel emplacement dans un segment. En fait, le 80386 supporte deux types de sauts proches. L'un utilise deux octets pour le déplacement. Cela permet de se déplacer en avant ou en arrière d'environ 32 000 octets. L'autre type utilise quatre octets pour le déplacement, ce qui, bien sûr, permet de se déplacer n'importe où dans le segment.

Le type de saut à deux octets peut être spécifié en plaçant le mot-clé WORD avant l'étiquette dans l'instruction JMP.

FAR ce saut permet de passer le contrôle à un autre segment de code. C'est une chose très rare en mode protégé 386.

Les étiquettes de code valides suivent les mêmes règles que les étiquettes de données. Les étiquettes de code sont définies en les plaçant dans le segment de code au début de l'instruction qu'ils étiquettent. Deux points sont placés à la fin de l'étiquette lors de sa définition. Ces deux points ne font pas partie du nom.

Image non disponible

Il y a beaucoup d'instructions de branchement conditionnel différentes. Elles prennent toutes une étiquette de code comme seul opérande. Les plus simples se contentent de regarder un drapeau dans le registre FLAGS pour déterminer si elles doivent sauter ou non. Voyez le Tableau 2.3 pour une liste de ces instructions (PF est le drapeau de parité qui indique si le nombre de bits dans les 8 bits de poids faible du résultat est pair ou impair).

Le pseudo-code suivant :

Sélectionnez
if ( EAX == 0 )

EBX = 1;

else

EBX = 2;

peut être écrit de cette façon en assembleur :

Sélectionnez
1 cmp eax, 0 ; positionne les drapeaux (ZF allumé si eax - 0 = 0)

2 jz thenblock ; si ZF est allumé, branchement vers thenblock

3 mov ebx, 2 ; partie ELSE du IF

4 jmp next ; saute par dessus la partie THEN du IF

5 thenblock :

6 mov ebx, 1 ; partie THEN du IF

7 next :

Les autres comparaisons ne sont pas si simples si l'on se contente des branchements conditionnels du Tableau 2.3. Pour illustrer, considérons le pseudo-code suivant :

Sélectionnez
if ( EAX >= 5 )

EBX = 1;

else

EBX = 2;

Si EAX est plus grand ou égal à 5, ZF peut être allumé ou non et SF sera égal à OF. Voici le code assembleur qui teste ces conditions (en supposant que EAX est signé) :

Sélectionnez
1 cmp eax, 5
2 js signon ; goto signon si SF = 1
3 jo elseblock ; goto elseblock si OF = 1 et SF = 0
4 jmp thenblock ; goto thenblock si SF = 0 et OF = 0
5 signon:
6 jo thenblock ; goto thenblock si SF = 1 et OF = 1
7 elseblock :
8 mov ebx, 2
9 jmp next
10 thenblock:
11 mov ebx, 1
12 next:

Le code ci-dessus est très confus. Heureusement, le 80x86 fournit des instructions de branchement additionnelles pour rendre ce type de test beaucoup plus simple. Il y a des versions signées et non signées de chacune.

Image non disponible

Le Tableau 2.4 montre ces instructions. Les branchements égal et non égal (JE et JNE) sont les mêmes pour les entiers signés et non signés (en fait, JE et JNE sont réellement identiques à JZ et JNZ, respectivement). Chacune des autres instructions de branchement a deux synonymes. Par exemple, regardez JL (jump less than, saut si inférieur à) et JNGE (jump not greater than or equal to, saut si non plus grand ou égal). Ce sont les mêmes instructions, car :

x < y => not(x y)

Les branchements non signés utilisent A pour above (au-dessus) et B pour below (en dessous) à la place de L et G.

En utilisant ces nouvelles instructions de branchement, le pseudo-code ci-dessus peut être traduit en assembleur plus facilement.

Sélectionnez
1 cmp eax, 5
2 jge thenblock
3 mov ebx, 2
4 jmp next
5 thenblock:
6 mov ebx, 1
7 next:

III-B-3. Les instructions de boucle

Le 80x86 fournit plusieurs instructions destinées à implémenter des boucles de style for. Chacune de ces instructions prend une étiquette de code comme seul opérande.

LOOP décrémente ECX, si ECX 6= 0, saute vers l'étiquette.

LOOPE, LOOPZ décrémente ECX (le registre FLAGS n'est pas modifié), si ECX 6= 0 et ZF = 1, saute.

LOOPNE, LOOPNZ décrémente ECX (FLAGS inchangé), si ECX 6= 0 et ZF = 0, saute.

Les deux dernières instructions de boucle sont utiles pour les boucles de recherche séquentielle. Le pseudo-code suivant :

Sélectionnez
sum = 0;

for ( i=10; i >0; i&#8722;&#8722; )

sum += i;

peut être traduit en assembleur de la façon suivante ;

Sélectionnez
1 mov eax, 0 ; eax est sum
2 mov ecx, 10 ; ecx est i
3 loop_start:
4 add eax, ecx
5 loop loop_start

III-C. Traduire les structures de contrôle standard

Cette section décrit comment les structures de contrôle standard des langages de haut niveau peuvent être implémentées en langage assembleur.

III-C-1. Instructions if

Le pseudo-code suivant :

Sélectionnez
if ( condition )
then_block;
else
else_block;

peut être implémenté de cette façon :

Sélectionnez
1 ; code pour positionner FLAGS
2 jxx else_block ; choisir xx afin de sauter si la condition est fausse
3 ; code pour le bloc then
4 jmp endif
5 else_block :
6 ; code pour le bloc else
7 endif :

S'il n'y a pas de else, le branchement else_block peut être remplacé par un branchement à endif.

Sélectionnez
1 ; code pour positionner FLAGS
2 jxx endif ; choisir xx afin de sauter si la condition est fausse
3 ; code pour le bloc then
4 endif :

III-C-2. Boucles while

La boucle while est une boucle avec un test au début :

Sélectionnez
while( condition ) {

corps de la boucle;

}

Cela peut être traduit en :

Sélectionnez
1 while :
2 ; code pour positionner FLAGS selon la condition
3 jxx endwhile ; choisir xx pour sauter si la condition est fausse
4 ; body of loop
5 jmp while
6 endwhile :

III-C-3. Boucles do while

La boucle do while est une boucle avec un test à la fin :

Sélectionnez
do {

corps de la boucle;

} while( condition );

Cela peut être traduit en :

Sélectionnez
1 do :

2 ; corps de la boucle

3 ; code pour positionner FLAGS selon la condition

4 jxx do ; choisir xx pour se brancher si la condition est vraie

III-D. Exemple : trouver des nombres premiers

Cette section détaille un programme qui trouve des nombres premiers.

Rappelez-vous que les nombres premiers ne sont divisibles que par 1 et par eux-mêmes. Il n'y a pas de formule pour les rechercher. La méthode de base qu'utilise ce programme est de rechercher les diviseurs de tous les nombres impairs(13) sous une limite donnée. Si aucun facteur ne peut être trouvé pour un nombre impair, il est premier. Voici l'algorithme de base écrit en C.

Sélectionnez
1 unsigned guess; / candidat courant pour être premier /

2 unsigned factor ; / diviseur possible de guess /

3 unsigned limit ; / rechercher les nombres premiers jusqu'à cette valeur /

4

5  printf ("Rechercher les nombres premiers jusqu'a : ");

6 scanf("%u", &limit);

7 printf ("2\n"); / traite les deux premiers nombres premiers comme /

8 printf ("3\n"); / des cas spéciaux /

9 guess = 5; / candidat initial /

10 while ( guess <= limit ) {

11 / recherche un diviseur du candidat /

12 factor = 3;

13 while ( factor factor < guess &&

14 guess % factor != 0 )

15 factor += 2;

16 if ( guess % factor != 0 )

17 printf ("%d\n", guess);

18 guess += 2; / ne regarder que les nombres impairs /

19 }

Voici la version assembleur :

prime.asm
Sélectionnez
1 %include "asm_io.inc"
2 segment .data
3 Message db "Rechercher les nombres premiers jusqu'a : ", 0
4
5  segment .bss
6 Limit resd 1 ; rechercher les nombres premiers jusqu'à cette limite
7 Guess resd 1 ; candidat courant pour être premier
8
9  segment .text
10 global _asm_main
11 _asm_main :
12 enter 0,0 ; routine d'initialisation
13 pusha
14
15 mov eax, Message
16 call print_string
17 call read_int ; scanf("%u", &limit);
18 mov [Limit], eax
19
20 mov eax, 2 ; printf("2\n");
21 call print_int
22 call print_nl
23 mov eax, 3 ; printf("3\n");
24 call print_int
25 call print_nl
26
27 mov dword [Guess], 5 ; Guess = 5;
28 while_limit : ; while ( Guess <= Limit )
29 mov eax,[Guess]
30 cmp eax, [Limit]
31 jnbe end_while_limit ; on utilise jnbe, car les nombres ne sont pas signes
32
33 mov ebx, 3 ; ebx est factor = 3;
34 while_factor:
35 mov eax,ebx
36 mul eax ; edx:eax = eax*eax
37 jo end_while_factor ; si la réponse ne tient pas dans eax seul
38 cmp eax, [Guess]
39 jnb end_while_factor ; si !(factor*factor < guess)
40 mov eax,[Guess]
41 mov edx,0
42 div ebx ; edx = edx:eax % ebx
43 cmp edx, 0
44 je end_while_factor ; si !(guess % factor != 0)
45
46 add ebx,2 ; factor += 2;
47 jmp while_factor
48 end_while_factor:
49 je end_if ; si !(guess % factor != 0)
50 mov eax,[Guess] ; printf("%u\n")
51 call print_int
52 call print_nl
53 end_if :
54 add dword [Guess], 2 ; guess += 2
55 jmp while_limit
56 end_while_limit:
57
58 popa
59 mov eax, 0 ; retour au C
60 leave
61 ret

IV. Opérations sur les bits

IV-A. Opérations de décalage

Le langage assembleur permet au programmeur de manipuler individuellement les bits des données. Une opération courante sur les bits est appelée décalage. Une opération de décalage déplace la position des bits d'une donnée. Les décalages peuvent être soit vers la gauche (i.e. vers les bits les plus significatifs) soit vers la droite (les bits les moins significatifs).

IV-A-1. Décalages logiques

Le décalage logique est le type le plus simple de décalage. Il décale d'une manière très simple. La Figure 3.1 montre l'exemple du décalage d'un nombre sur un octet.

Image non disponible

Notez que les nouveaux bits sont toujours à 0. Les instructions SHL et SHR sont respectivement utilisées pour décaler à gauche et à droite. Ces instructions permettent de décaler de n'importe quel nombre de positions.

Le nombre de positions à décaler peut soit être une constante, soit être stocké dans le registre CL. Le dernier bit décalé de la donnée est stocké dans le drapeau de retenue. Voici quelques exemples :

Sélectionnez
1 mov ax, 0C123H

2 shl ax, 1 ; décale d'1 bit à gauche, ax = 8246H, CF = 1

3 shr ax, 1 ; décale d'1 bit à droite, ax = 4123H, CF = 0

4 shr ax, 1 ; décale d'1 bit à droite, ax = 2091H, CF = 1

5 mov ax, 0C123H

6 shl ax, 2 ; décale de 2 bits à gauche, ax = 048CH, CF = 1

7 mov cl, 3

8 shr ax, cl ; décale de 3 bits à droite, ax = 0091H, CF = 1

IV-A-2. Utilisation des décalages

La multiplication et la division rapides sont les utilisations les plus courantes des opérations de décalage. Rappelez-vous que dans le système décimal, la multiplication et la division par une puissance de dix sont simples, on décale simplement les chiffres. C'est également vrai pour les puissances de deux en binaire. Par exemple, pour multiplier par deux le nombre binaire 10112 (ou 11 en décimal), décalez d'un cran vers la gauche pour obtenir 101102 (ou 22). Le quotient d'une division par une puissance de 2 est le résultat d'un décalage à droite. Pour diviser par 2, utilisez un décalage d'une position à droite ; pour diviser par 4 (22), décalez à droite de deux positions ; pour diviser par 8 (23), décalez de 3 positions vers la droite, etc. Les instructions de décalage sont très basiques et sont beaucoup plus rapides que les instructions MUL et DIV correspondantes !

En fait, les décalages logiques peuvent être utilisés pour multiplier ou diviser des valeurs non signées. Ils ne fonctionnent généralement pas pour les valeurs signées. Considérons la valeur sur deux octets FFFF (−1 signé). Si on lui applique un décalage logique d'une position vers la droite, le résultat est 7FFF ce qui fait +32 767 ! Un autre type de décalage doit être utilisé pour les valeurs signées.

IV-A-3. Décalages arithmétiques

Ces décalages sont conçus pour permettre à des nombres signés d'être rapidement multipliés et divisés par des puissances de 2. Ils assurent que le bit de signe est traité correctement.

SAL Shift Arithmetic Left (décalage arithmétique à gauche) - Cette instruction est juste un synonyme pour SHL. Elle est assemblée pour donner exactement le même code machine que SHL. Tant que le bit de signe n'est pas changé par le décalage, le résultat sera correct.

SAR Shift Arithmetic Right (décalage arithmétique à droite) - C'est une nouvelle instruction qui ne décale pas le bit de signe (i.e. le msb) de son opérande. Les autres bits sont décalés normalement sauf que les nouveaux bits qui entrent par la gauche sont des copies du bit de signe (c'est-à-dire que si le bit de signe est à 1, les nouveaux bits sont également à 1). Donc, si un octet est décalé avec cette instruction, seuls les 7 bits de poids faible sont décalés. Comme pour les autres décalages, le dernier bit sorti est stocké dans le drapeau de retenue.

Sélectionnez
1 mov ax, 0C123H

2 sal ax, 1 ; ax = 8246H, CF = 1

3 sal ax, 1 ; ax = 048CH, CF = 1

4 sar ax, 2 ; ax = 0123H, CF = 0

IV-A-4. Décalages circulaires

Les instructions de décalage circulaire fonctionnent comme les décalages logiques excepté que les bits perdus à un bout sont réintégrés à l'autre. Donc, la donnée est traitée comme s'il s'agissait d'une structure circulaire. Les deux instructions de rotation les plus simples sont ROL et ROR qui effectuent des rotations à gauche et à droite, respectivement. Comme pour les autres décalages, ceux-ci laissent une copie du dernier bit sorti dans le drapeau de retenue.

Sélectionnez
1 mov ax, 0C123H

2 rol ax, 1 ; ax = 8247H, CF = 1

3 rol ax, 1 ; ax = 048FH, CF = 1

4 rol ax, 1 ; ax = 091EH, CF = 0

5 ror ax, 2 ; ax = 8247H, CF = 1

6 ror ax, 1 ; ax = C123H, CF = 1

Il y a deux instructions de rotation supplémentaires qui décalent les bits de la donnée et le drapeau de retenue appelés RCL et RCR. Par exemple, si on applique au registre AX une rotation avec ces instructions, elle est appliquée aux 17 bits constitués de AX et du drapeau de retenue.

Sélectionnez
1 mov ax, 0C123H

2 clc ; eteint le drapeau de retenue (CF = 0)

3 rcl ax, 1 ; ax = 8246H, CF = 1

4 rcl ax, 1 ; ax = 048DH, CF = 1

5 rcl ax, 1 ; ax = 091BH, CF = 0

6 rcr ax, 2 ; ax = 8246H, CF = 1

7 rcr ax, 1 ; ax = C123H, CF = 0

IV-A-5. Application simple

Voici un extrait de code qui compte le nombre de bits qui sont allumés (i.e. à 1) dans le registre EAX.

Sélectionnez
1 mov bl, 0 ; bl contiendra le nombre de bits ALLUMES
2 mov ecx, 32 ; ecx est le compteur de boucle
3 count_loop:
4 shl eax, 1 ; decale un bit dans le drapeau de retenue
5 jnc skip_inc ; si CF == 0, goto skip_inc
6 inc bl
7 skip_inc :
8 loop count_loop

Le code ci-dessus détruit la valeur originale de EAX (EAX vaut zéro à la fin de la boucle). Si l'on voulait conserver la valeur de EAX, la ligne 4 pourrait être remplacée par rol eax, 1.

IV-B. Opérations booléennes niveau bit

Image non disponible

Il y a quatre opérateurs booléens courants : AND, OR, XOR et NOT. Une table de vérité montre le résultat de chaque opération pour chaque valeur possible de ses opérandes.

IV-B-1. L'opération ET

Le résultat d'un ET sur deux bits vaut 1 uniquement si les deux bits sont à 1, sinon, le résultat vaut 0, comme le montre la table de vérité du Tableau 3.1.

Les processeurs supportent ces opérations comme des instructions agissant de façon indépendante sur tous les bits de la donnée en parallèle. Par exemple, si on applique un ET au contenu de AL et BL, l'opération ET de base est appliquée à chacune des 8 paires de bits correspondantes dans les deux registres, comme le montre la Figure 3.2. Voici un exemple de code :

Sélectionnez
1 mov ax, 0C123H

2 and ax, 82F6H ; ax = 8022H

IV-B-2. L'opération OU

Image non disponible

Le OU de 2 bits vaut 0 uniquement si les deux bits valent 0, sinon le résultat vaut 1 comme le montre la table de vérité du Tableau 3.2. Voici un exemple de code :

Sélectionnez
1 mov ax, 0C123H
2 or ax, 0E831H ; ax = E933H

IV-B-3. L'opération XOR

Le OU exclusif de deux bits vaut 0 uniquement si les deux bits sont égaux, sinon, le résultat vaut 1 comme le montre la table de vérité du Tableau 3.3.

Voici un exemple de code :

Sélectionnez
1 mov ax, 0C123H

2 xor ax, 0E831H ; ax = 2912H

IV-B-4. L'opération NOT

Image non disponible

L'opération NOT est une opération unaire (i.e. elle agit sur un seul opérande, pas deux comme les opérations binaires de type ET). Le NOT d'un bit est la valeur opposée du bit comme le montre la table de vérité du Tableau 3.4. Voici un exemple de code :

Sélectionnez
1 mov ax, 0C123H

2 not ax ; ax = 3EDCH

Notez que le NOT donne le complément à un. Contrairement aux autres opérations niveau bit, l'instruction NOT ne change aucun des bits du registre FLAGS.

IV-B-5. L'instruction TEST

L'instruction TEST effectue une opération AND, mais ne stocke pas le résultat. Elle positionne le registre FLAGS selon ce que ce dernier aurait été après un AND (comme l'instruction CMP qui effectue une soustraction, mais ne fait que positionner FLAGS). Par exemple, si le résultat était zéro, ZF serait allumé.

IV-B-6. Utilisation des opérations sur les bits

Les opérations sur les bits sont très utiles pour manipuler individuellement les bits d'une donnée sans modifier les autres bits. Le Tableau 3.5 montre trois utilisations courantes de ces opérations.

Image non disponible

Voici un exemple de code, implémentant ces idées.

Sélectionnez
1 mov ax, 0C123H

2 or ax, 8 ; allumer le bit 3, ax = C12BH

3 and ax, 0FFDFH ; éteindre le bit 5, ax = C10BH

4 xor ax, 8000H ; inverser le bit 31, ax = 410BH

5 or ax, 0F00H ; allumer un quadruplet, ax = 4F0BH

6 and ax, 0FFF0H ; éteindre un quadruplet, ax = 4F00H

7 xor ax, 0F00FH ; inverser des quadruplets, ax = BF0FH

8 xor ax, 0FFFFH ; complément à 1, ax = 40F0H

L'opération ET peut aussi être utilisée pour trouver le reste d'une division par une puissance de deux. Pour trouver le reste d'une division par 2i, effectuez un ET sur le nombre avec un masque valant 2i − 1. Ce masque contiendra des 1 du bit 0 au bit i−1. Ce sont tout simplement ces bits qui correspondent au reste. Le résultat du ET conservera ces bits et mettra les autres à zéro. Voici un extrait de code qui trouve le quotient et le reste de la division de 100 par 16.

Sélectionnez
1 mov eax, 100 ; 100 = 64H

2 mov ebx, 0000000FH ; masque = 16 - 1 = 15 ou F

3 and ebx, eax ; ebx = reste = 4

En utilisant le registre CL il est possible de modifier n'importe quel(s) bit(s) d'une donnée. Voici un exemple qui allume un bit de EAX. Le numéro du bit à allumer est stocké dans BH.

Sélectionnez
1 mov cl, bh ; tout d'abord, construire le nombre pour le OU

2 mov ebx, 1

3 shl ebx, cl ; décalage à gauche cl fois

4 or eax, ebx ; allume le bit

Éteindre un bit est un tout petit peut plus dur.

Sélectionnez
1 mov cl, bh ; tout d'abord, construire le nombre pour le ET

2 mov ebx, 1

3 shl ebx, cl ; décalage à gauche cl fois

4 not ebx ; inverse les bits

5 and eax, ebx ; éteint le bit

Le code pour inverser un bit est laissé en exercice au lecteur.

Il n'est pas rare de voir l'instruction déroutante suivante dans un programme 80x86 :

Sélectionnez
xor eax, eax ; eax = 0

Un nombre auquel on applique un XOR avec lui-même donne toujours zéro.

Cette instruction est utilisée, car son code machine est plus petit que l'instruction MOV correspondante.

IV-C. Éviter les branchements conditionnels

Les processeurs modernes utilisent des techniques très sophistiquées pour exécuter le code le plus rapidement possible. Une technique répandue est appelée exécution spéculative. Cette technique utilise les possibilités de traitement en parallèle du processeur pour exécuter plusieurs instructions à la fois. Les branchements conditionnels posent un problème à ce type de fonctionnement.

Le processeur, en général, ne sait pas si le branchement sera effectué ou pas. Selon qu'il est effectué ou non, un ensemble d'instructions différent sera exécuté. Les processeurs essaient de prévoir si le branchement sera effectué. Si la prévision est mauvaise, le processeur a perdu son temps en exécutant le mauvais code.

Une façon d'éviter ce problème est d'éviter d'utiliser les branchements conditionnels lorsque c'est possible. Le code d'exemple de III.A.5 fournit un exemple simple de la façon de le faire. Dans l'exemple précédent, les bits allumés du registre EAX sont décomptés. Il utilise un branchement pour éviter l'instruction INC. La Figure 3.3 montre comment le branchement peut être retiré en utilisant l'instruction ADC pour ajouter directement le drapeau de retenue.

Image non disponible

Les instructions SETxx fournissent un moyen de retirer les branchements dans certains cas. Ces instructions positionnent la valeur d'un registre ou d'un emplacement mémoire d'un octet à zéro ou à un selon l'état du registre FLAGS. Les caractères après SET sont les mêmes que pour les branchements conditionnels. Si la condition correspondante au SETxx est vraie, le résultat stocké est un, s'il est faux, zéro est stocké. Par exemple :

Sélectionnez
setz al ; AL = 1 si ZF est allume, sinon 0

En utilisant ces instructions, il est possible de développer des techniques ingénieuses qui calculent des valeurs sans branchement.

Par exemple, considérons le problème de la recherche de la plus grande de deux valeurs. L'approche standard pour résoudre ce problème serait d'utiliser un CMP et un branchement conditionnel pour déterminer la valeur la plus grande. Le programme exemple ci-dessous montre comment le maximum peut être trouvé sans utiliser aucun branchement.

Sélectionnez
1 ; file : max.asm
2 %include "asm_io.inc"
3 segment .data
4
5  message1 db "Entrez un nombre : ",0
6 message2 db "Entrez un autre nombre : ", 0
7 message3 db "Le plus grand nombre est : ", 0
8
9  segment .bss
10
11 input1 resd 1 ; premier nombre entre
12
13 segment .text
14 global _asm_main
15 _asm_main :
16 enter 0,0 ; routine d'initialisation
17 pusha
18
19 mov eax, message1 ; affichage du premier message
20 call print_string
21 call read_int ; saisie du premier nombre
22 mov [input1], eax
23
24 mov eax, message2 ; affichage du second message
25 call print_string
26 call read_int ; saisie du second nombre (dans eax)
27
28 xor ebx, ebx ; ebx = 0
29 cmp eax, [input1] ; compare le premier et le second nombre
30 setg bl ; ebx = (input2 > input1) ? 1 : 0
31 neg ebx ; ebx = (input2 > input1) ? 0xFFFFFFFF : 0
32 mov ecx, ebx ; ecx = (input2 > input1) ? 0xFFFFFFFF : 0
33 and ecx, eax ; ecx = (input2 > input1) ? input2 : 0
34 not ebx ; ebx = (input2 > input1) ? 0 : 0xFFFFFFFF
35 and ebx, [input1] ; ebx = (input2 > input1) ? 0 : input1
36 or ecx, ebx ; ecx = (input2 > input1) ? input2 : input1
37
38 mov eax, message3 ; affichage du résultat
39 call print_string
40 mov eax, ecx
41 call print_int
42 call print_nl
43
44 popa
45 mov eax, 0 ; retour au C
46 leave
47 ret

L'astuce est de créer un masque de bits qui peut être utilisé pour sélectionner la valeur correcte pour le maximum. L'instruction SETG à la ligne 30 positionne BL à 1 si la seconde saisie est la plus grande ou à 0 sinon. Ce n'est pas le masque de bits désiré.

Pour créer le masque nécessaire, la ligne 31 utilise l'instruction NEG sur le registre EBX (notez que EBX a été positionné à 0 précédemment). Si EBX vaut 0, cela ne fait rien ; cependant, si EBX vaut 1, le résultat est la représentation en complément à 2 de -1 soit 0xFFFFFFFF. C'est exactement le masque de bits désiré.

Le code restant utilise ce masque de bits pour sélectionner la saisie correspondant au plus

grand nombre.

Une autre astuce est d'utiliser l'instruction DEC. Dans le code ci-dessus, si NEG est remplacé par DEC, le résultat sera également 0 ou 0xFFFFFFFF. Cependant, les valeurs sont inversées par rapport à l'utilisation de l'instruction NEG.

IV-D. Manipuler les bits en C

IV-D-1. Les opérateurs niveau bit du C

Contrairement à certains langages de haut niveau, le C fournit des opérateurs pour les opérations niveau bit. L'opération ET est représentée par l'opérateur binaire &(14). L'opération OU est représentée par l'opérateur binaire |. L'opération XOR est représentée par l'opérateur binaire ^ . Et l'opération NOT est représentée par l'opérateur unaire ~ .

Les opérations de décalage sont effectuées au moyen des opérateurs binaires et du C. L'opérateur effectue les décalages à gauche et l'opérateur effectue les décalages à droite. Ces opérateurs prennent deux opérandes. L'opérande de gauche est la valeur à décaler et l'opérande de droite est le nombre de bits à décaler. Si la valeur à décaler est d'un type non signé, un décalage logique est effectué. Si la valeur est d'un type signé (comme int), alors un décalage arithmétique est utilisé. Voici un exemple en C utilisant ces opérateurs :

Sélectionnez
1 short int s ; / on suppose que les short int font 16 bits /

2 short unsigned u;

3 s = &#8722;1; / s = 0xFFFF (complement a 2) /

4 u = 100; / u = 0x0064 /
5 u = u | 0x0100; / u = 0x0164 /
6 s = s & 0xFFF0; / s = 0xFFF0 /
7 s = s ^ u; / s = 0xFE94 /
8 u = u << 3; / u = 0x0B20 (décalage logique) /
9 s = s >> 2; / s = 0xFFA5 (décalage arithmetique) /

IV-D-2. Utiliser les opérateurs niveau bit en C

Les opérateurs niveau bit sont utilisés en C pour les mêmes raisons qu'ils le sont en assembleur. Ils permettent de manipuler les bits d'une donnée individuellement et peuvent être utilisés pour des multiplications et des divisions rapides. En fait, un compilateur C malin utilisera automatiquement un décalage pour une multiplication du type x *= 2.

Beaucoup d'API(15) de systèmes d'exploitation (comme POSIX(16) et Win32) contiennent des fonctions qui utilisent des opérandes donc les données sont codées sous la forme de bits. Par exemple, les systèmes POSIX conservent les permissions sur les fichiers pour trois différents types d'utilisateurs : utilisateur (un nom plus approprié serait propriétaire), groupe et autres. Chaque type d'utilisateur peut recevoir la permission de lire, écrire et/ou exécuter un fichier. Pour changer les permissions d'un fichier, le programmeur C doit manipuler des bits individuels. POSIX définit plusieurs macros pour l'aider (voir Tableau 3.6).

Image non disponible

La fonction chmod peut être utilisée pour définir les permissions sur un fichier. Cette fonction prend deux paramètres, une chaine avec le nom du fichier à modifier et un entier(17) avec les bits appropriés d'allumés pour les permissions désirées. Par exemple, le code ci-dessous définit les permissions pour permettre au propriétaire du fichier de lire et d'écrire dedans, au groupe de lire le fichier et d'interdire l'accès aux autres.

Sélectionnez
chmod("foo", S_IRUSR | S_IWUSR | S_IRGRP );

La fonction POSIX stat peut être utilisée pour récupérer les bits de permission en cours pour un fichier. En l'utilisant avec la fonction chmod,

il est possible de modifier certaines des permissions sans changer les autres.

Voici un exemple qui retire l'accès en écriture aux autres et ajoute les droits de lecture pour le propriétaire. Les autres permissions ne sont pas altérées.

Sélectionnez
1 struct stat le_stats ; / structure utilisee par stat () /

2 stat ("foo", & le_stats ); / lit les infos du fichier

3 le_stats .st_mode contient les bits de permission /

4 chmod("foo", ( le_stats .st_mode & ~S_IWOTH) | S_IRUSR);

IV-E. Représentations big et little endian

Le Chapitre II a introduit les concepts de représentations big et little endian des données multioctets. Cependant, l'auteur s'est rendu compte que pour beaucoup de gens, ce sujet est confus. Cette section couvre le sujet plus en détail.

Le lecteur se souvient sûrement que le caractère big ou little endian fait référence à l'ordre dans lequel les octets (pas les bits) d'un élément de données multioctets sont stockés en mémoire. La représentation big endian est la méthode la plus intuitive. Elle stocke l'octet le plus significatif en premier, puis le second octet le plus significatif, etc. En d'autres termes, les gros (big) bits sont stockés en premier. La méthode little endian stocke les octets dans l'ordre inverse (moins significatif en premier). La famille des processeurs x86 utilise la représentation little endian.

Par exemple, considérons le double mot représentant 1234567816. En représentation big endian, les octets seraient stockés 12 34 56 78. En représentation little endian, les octets seraient stockés 78 56 34 12.

Le lecteur est probablement en train de se demander pourquoi n'importe quel concepteur de puce sain d'esprit utiliserait la représentation little endian ?

Les ingénieurs de chez Intel étaient-ils sadiques pour infliger à une multitude de programmeurs cette représentation qui prête à confusion ? Il peut sembler que le processeur ait à faire du travail supplémentaire pour stocker les octets en mémoire dans l'ordre inverse (et pour les réinverser lorsqu'il lit à partir de la mémoire). En fait, le processeur ne fait aucun travail supplémentaire pour lire et écrire en mémoire en utilisant le format little endian. Il faut comprendre que le processeur est constitué de beaucoup de circuits électroniques qui ne travaillent que sur des valeurs de un bit. Les bits (et les octets) peuvent être dans n'importe quel ordre dans le processeur.

Considérons le registre de deux octets AX. Il peut être décomposé en deux registres d'un octet : AH et AL. Il y a des circuits dans le processeur qui conservent les valeurs de AH and AL. Ces circuits n'ont pas d'ordre dans le processeur. C'est-à-dire que les circuits pour AH ne sont pas avant ou après les circuits pour AL. Une instruction MOV qui copie la valeur de AX en mémoire copie la valeur de AL puis de AH. Ce n'est pas plus dur pour le processeur que de stocker AH en premier.

Le même argument s'applique aux bits individuels d'un octet. Ils ne sont pas réellement dans un ordre déterminé dans les circuits du processeur (ou en mémoire en ce qui nous concerne). Cependant, comme les bits individuels ne peuvent pas être adressés dans le processeur ou en mémoire, il n'y a pas de façon de savoir (et aucune raison de s'en soucier) l'ordre dans lequel ils sont conservés à l'intérieur du processeur.

Le code C de la Figure 3.4 montre comment le caractère big ou little endian d'un processeur peut être déterminé.

Image non disponible

Le pointeur p traite la variable word comme un tableau de caractères de deux éléments. Donc, p[0] correspond au premier octet de word en mémoire dont la valeur dépend du caractère big ou little endian du processeur.

IV-E-1. Quand se soucier du caractère big ou little endian

Pour la programmation courante, le caractère big ou little endian du processeur n'est pas important. Le moment le plus courant où cela devient important est lorsque des données binaires sont transférées entre différents systèmes informatiques. Cela se fait habituellement en utilisant un type quelconque de média physique (comme un disque) ou via un réseau. Comme les données ASCII sont sur un seul octet, le caractère big ou little endian n'est pas un problème.

Avec l'avènement des jeux de caractères multioctets comme UNICODE, le caractère big ou little endian devient important même pour les données texte. UNICODE supporte les deux types de représentation et a un mécanisme pour indiquer celle qui est utilisée pour représenter les données.

Tous les en-têtes internes de TCP/IP stockent les entiers au format big

endian (appelé ordre des octets réseau). Les bibliothèques TCP/IP offrent des fonctions C pour résoudre les problèmes de représentation big ou little endian d'une façon portable. Par exemple, la fonction htonl () convertit un double-mot (ou un entier long) depuis le format hôte vers le format réseau.

La fonction function ntohl () effectue la transformation inverse(18). Pour un système big endian les deux fonctions retournent leur paramètre inchangé. Cela permet d'écrire des programmes réseau qui compileront et s'exécuteront correctement sur n'importe quel système sans tenir compte de la représentation utilisée. Pour plus d'information sur les représentations big et little endian et la programmation réseau, voyez l'excellent livre de W. Richard Steven : UNIX Network Programming.

La Figure 3.5 montre une fonction C qui passe d'une représentation à l'autre pour un double mot.

Image non disponible

Le processeur 486 a introduit une nouvelle instruction machine appelée BSWAP qui inverse les octets de n'importe quel registre 32 bits. Par exemple,

Sélectionnez
bswap edx ; échange les octets de edx

L'instruction ne peut pas être utilisée sur des registres de 16 bits. Cependant, l'instruction XCHG peut être utilisée pour échanger les octets des registres 16 bits pouvant être décomposés en registres de 8 bits. Par exemple :

Sélectionnez
xchg ah,al ; échange les octets de ax

IV-F. Compter les bits

Plus haut, nous avons donné une technique intuitive pour compter le nombre de bits allumés dans un double-mot. Cette section décrit d'autres méthodes moins directes de le faire pour illustrer les opérations sur les bits dont nous avons parlé dans ce chapitre.

IV-F-1. Méthode une

La première méthode est très simple, mais pas évidente. La Figure 3.6 en montre le code.

Image non disponible

Comment fonctionne cette méthode ? À chaque itération de la boucle, un bit est éteint dans data. Quand tous les bits sont éteints ((i.e. lorsque data vaut zéro), la boucle s'arrête. Le nombre d'itérations requises pour mettre data à zéro est égal au nombre de bits dans la valeur originale de data.

La ligne 6 est l'endroit où un bit de data est éteint. Comment cela marche ? Considérons la forme générale de la représentation binaire de data et le 1 le plus à droite dans cette représentation. Par dénifition, chaque bit après ce 1 est à zéro. Maintenant, que sera la représentation de data - 1 ? Les bits à gauche du 1 le plus à droite seront les mêmes que pour data, mais à partir du 1 le plus à droite, les bits seront les compléments des bits originaux de data. Par exemple :

data = xxxxx10000

data - 1 = xxxxx01111

où les x sont les mêmes pour les deux nombres. Lorsque l'on applique un ET sur data avec data - 1, le résultat mettra le 1 le plus à droite de data à zéro et laissera les autres bits inchangés.

IV-F-2. Méthode deux

Un tableau de recherche peut également être utilisé pour contrer les bits de n'importe quel double-mot. L'approche intuitive serait de précalculer le nombre de bits de chaque double-mot et de le stocker dans un tableau.

Cependant, il y a deux problèmes relatifs à cette approche. Il y a à peu près 4 milliards de valeurs de doubles-mots ! Cela signifie que le tableau serait très gros et que l'initialiser prendrait beaucoup de temps (en fait, à moins que l'on ait l'intention d'utiliser le tableau plus de 4 milliards de fois, cela prendrait plus de temps de l'initialiser que cela n'en prendrait de calculer le nombre de bits avec la méthode un !).

Une méthode plus réaliste calculerait le nombre de bits pour toutes les valeurs possibles d'octet et les stockerait dans un tableau. Le double-mot peut alors être décomposé en quatre valeurs d'un octet. Le nombre de bits pour chacune de ces quatre valeurs d'un octet est recherché dans le tableau et additionné aux autres pour trouver le nombre de bits du double-mot original.

La Figure 3.7 montre le code implémentant cette approche.

Image non disponible

La fonction initialize_count_bits doit être appelée avant le premier appel à la fonction count_bits. Cette fonction initialise le tableau global byte_bit_count. La fonction count_bits ne considère pas la variable data comme un double-mot, mais comme un tableau de quatre octets. Donc, dword[0] est un des octets de data (soit le moins significatif, soit le plus significatif selon que le matériel est little ou big endian, respectivement).

Bien sûr, on peut utiliser une instruction comme :

(data >> 24) & 0x000000FF

pour trouver la valeur de l'octet le plus significatif et des opérations similaires pour les autres octets, cependant, ces opérations seraient plus lentes qu'une référence à un tableau.

Un dernier point, une boucle for pourrait facilement être utilisée pour calculer la somme des lignes 22 et 23. Mais, cela inclurait le supplément de l'initialisation de l'indice, sa comparaison après chaque itération et son incrémentation. Calculer la somme comme une somme explicite de quatre valeurs est plus rapide. En fait, un compilateur intelligent convertirait la version avec une boucle for en une somme explicite. Ce procédé de réduire ou éliminer les itérations d'une boucle est une technique d'optimisation de compilateur appelée loop unrolling (déroulage de boucle).

IV-F-3. Méthode trois

Il y a encore une méthode intelligente de compter les bits allumés d'une donnée. Cette méthode ajoute littéralement les 1 et les 0 de la donnée.

La somme est égale au nombre de 1 dans la donnée. Par exemple, considérons le comptage des 1 d'un octet stocké dans une variable nommée data. La première étape est d'effectuer l'opération suivante :

Sélectionnez
data = (data & 0x55) + ((data >> 1) & 0x55);

Pourquoi faire cela ? La constante hexa 0x55 vaut 01010101 en binaire. Dans le premier opérande de l'addition, on effectue un ET sur data avec elle, les bits sur des positions impaires sont supprimés. Le second opérande ((data>> 1) & 0x55) commence par déplacer tous les bits à des positions paires vers une position impaire et utilise le même masque pour supprimer ces bits.

Maintenant, le premier opérande contient les bits impairs et le second, les bits pairs de data. Lorsque ces deux opérandes sont additionnés, les bits pairs et les bits impairs de data sont additionnés. Par exemple, si data vaut 101100112, alors :

Image non disponible

L'addition à droite montre les bits additionnés ensemble. Les bits de l'octet sont divisés en deux champs de 2 bits pour montrer qu'il y a en fait quatre additions indépendantes. Comme la plus grande valeur que ces sommes peuvent prendre est deux, il n'est pas possible qu'une somme déborde de son champ et corrompe l'une des autres sommes.

Bien sûr, le nombre total de bits n'a pas encore été calculé. Cependant, la même technique que celle qui a été utilisée ci-dessus peut être utilisée pour calculer le total en une série d'étapes similaires. L'étape suivante serait :

data = (data & 0x33) + ((data >> 2) & 0x33);

En continuant l'exemple du dessus (souvenez-vous que data vaut maintenant 011000102).

Image non disponible

Il y a maintenant deux champs de 4 bits additionnés individuellement. La prochaine étape est d'additionner ces deux sommes de bits ensemble pour former le résultat final :

data = (data & 0x0F) + ((data >> 4) & 0x0F);

En utilisant l'exemple ci-dessus (avec data égale à 001100102) :

Image non disponible

Maintenant, data vaut 5 ce qui est le résultat correct. La Figure 3.8 montre une implémentation de cette méthode qui compte les bits dans un double-mot.

Image non disponible

Elle utilise une boucle for pour calculer la somme. Il serait plus rapide de dérouler la boucle, cependant, la boucle rend plus claire la façon dont la méthode se généralise à différentes tailles de données.

V. Sous-programmes

Ce chapitre explique comment utiliser des sous-programmes pour créer des programmes modulaires et s'interfacer avec des langages de haut niveau (comme le C). Les fonctions et les procédures sont des exemples de sous-programmes dans les langages de haut niveau.

Le code qui appelle le sous-programme et le sous-programme lui-même doivent se mettre d'accord sur la façon de se passer les données. Ces règles sur la façon de passer les données sont appelées conventions d'appel. Une grande partie de ce chapitre traitera des conventions d'appel standard du C qui peuvent être utilisées pour interfacer des sous-programmes assembleur avec des programmes C. Celles-ci (et d'autres conventions) passent souvent les adresses des données (i.e. des pointeurs) pour permettre au sous-programme d'accéder aux données en mémoire.

V-A. Adressage indirect

L'adressage indirect permet aux registres de se comporter comme des pointeurs. Pour indiquer qu'un registre est utilisé indirectement comme un pointeur, il est entouré par des crochets ([ ]). Par exemple :

Sélectionnez
1 mov ax, [Data] ; adressage mémoire direct normal d'un mot

2 mov ebx, Data ; ebx = & Data

3 mov ax, [ebx] ; ax = *ebx

Comme AX contient un mot, la ligne 3 lit un mot commençant à l'adresse stockée dans EBX. Si AX était remplacé par AL, un seul octet serait lu.

Il est important de réaliser que les registres n'ont pas de types comme les variables en C. Ce sur quoi EBX est censé pointer est totalement déterminé par les instructions utilisées. Si EBX est utilisé de manière incorrecte, il n'y aura souvent pas d'erreur signalée de la part de l'assembleur, cependant, le programme ne fonctionnera pas correctement. C'est une des nombreuses raisons pour lesquelles la programmation assembleur est plus sujette à erreur que la programmation de haut niveau.

Tous les registres 32 bits généraux (EAX, EBX, ECX, EDX) et d'index (ESI, EDI) peuvent être utilisés pour l'adressage indirect. En général, les registres 16 et 8 bits ne le peuvent pas.

V-B. Exemple de sous-programme simple

Un sous-programme est une unité de code indépendante qui peut être utilisée depuis différentes parties du programme. En d'autres termes, un sous-programme est comme une fonction en C. Un saut peut être utilisé pour appeler le sous-programme, mais le retour présente un problème. Si le sous-programme est destiné à être utilisé par différentes parties du programme, il doit revenir à la section de code qui l'a appelé. Donc, le retour du sous-programme ne peut pas être codé en dur par un saut vers une étiquette. Le code ci-dessous montre comment cela peut être réalisé en utilisant une forme indirecte de l'instruction JMP. Cette forme de l'instruction utilise la valeur d'un registre pour déterminer où sauter (donc, le registre agit plus comme un pointeur de fonction du C). Voici le premier programme du Chapitre II réécrit pour utiliser un sous-programme.

sub1.asm
Sélectionnez
1 ; fichier : sub1.asm
2 ; Programme d'exemple de sous-programme
3 %include "asm_io.inc"
4
5  segment .data
6 prompt1 db "Entrez un nombre : ", 0 ; ne pas oublier le zéro terminal
7 prompt2 db "Entrez un autre nombre : ", 0
8 outmsg1 db "Vous avez entré ", 0
9 outmsg2 db " et ", 0
10 outmsg3 db ", la somme des deux vaut ", 0
11
12 segment .bss
13 input1 resd 1
14 input2 resd 1
15
16 segment .text
17 global _asm_main
18 _asm_main :
19 enter 0,0 ; routine d'initialisation
20 pusha
21
22 mov eax, prompt1 ; affiche l'invite
23 call print_string
24
25 mov ebx, input1 ; stocke l'adresse de input1 dans ebx
26 mov ecx, ret1 ; stocke l'adresse de retour dans ecx
27 jmp short get_int ; lit un entier
28 ret1:
29 mov eax, prompt2 ; affiche l'invite
30 call print_string
31
32 mov ebx, input2
33 mov ecx, $ + 7 ; ecx = cette addresse + 7
34 jmp short get_int
35
36 mov eax, [input1] ; eax = dword dans input1
37 add eax, [input2] ; eax += dword dans input2
38 mov ebx, eax ; ebx = eax
39
40 mov eax, outmsg1
41 call print_string ; affiche le premier message
42 mov eax, [input1]
43 call print_int ; affiche input1
44 mov eax, outmsg2
45 call print_string ; affiche le second message
46 mov eax, [input2]
47 call print_int ; affiche input2
48 mov eax, outmsg3
49 call print_string ; affiche le troisième message
50 mov eax, ebx
51 call print_int ; affiche la somme (ebx)
52 call print_nl ; retour à la ligne
53
54 popa
55 mov eax, 0 ; retour au C
56 leave
57 ret
58 ; subprogram get_int
59 ; Paramètres :
60 ; ebx - addresse du dword dans lequel stocker l'entier
61 ; ecx - addresse de l'instruction vers laquelle retourner
62 ; Notes :
63 ; la valeur de eax est perdue
64 get_int:
65 call read_int
66 mov [ebx], eax ; stocke la saisie en mémoire
67 jmp ecx sub1.;asrmetour à l'appelant

Le sous-programme get_int utilise une convention d'appel simple, basée sur les registres. Il s'attend à ce que le registre EBX contienne l'adresse du DWORD dans lequel stocker le nombre saisi et à ce que le registre ECX contiennent l'adresse de l'instruction vers laquelle retourner.

Dans les lignes 25 à 28, l'étiquette ret1 est utilisée pour calculer cette adresse de retour.

Dans les lignes 32 à 34, l'opérateur $ est utilisé pour calculer l'adresse de retour. L'opérateur $ retourne l'adresse de la ligne sur laquelle il apparaît. L'expression $ + 7 calcule l'adresse de l'instruction MOV de la ligne 36.

Ces deux calculs d'adresses de code de retour sont compliqués. La première méthode requiert la définition d'une étiquette pour tout appel de sous-programme.

La seconde méthode ne requiert pas d'étiquette, mais nécessite de réfléchir attentivement. Si un saut proche avait été utilisé à la place d'un saut court, le nombre à ajouter à $ n'aurait pas été 7 !

Heureusement, il y a une façon plus simple d'appeler des sous-programmes. Cette méthode utilise la pile.

V-C. La pile

Beaucoup de processeurs ont un support intégré pour une pile. Une pile est une liste Last-In First-Out (LIFO, dernier entré, premier sorti). La pile est une zone de la mémoire qui est organisée de cette façon. L'instruction PUSH ajoute des données à la pile et l'instruction POP retire une donnée. La donnée retirée est toujours la dernière donnée ajoutée (c'est pourquoi on appelle ce genre de liste dernier entré, premier sorti).

Le registre de segment SS spécifie le segment qui contient la pile (habituellement c'est le même que celui qui contient les données). Le registre ESP contient l'adresse de la donnée qui sera retirée de la pile. On dit que cette donnée est au sommet de la pile. Les données ne peuvent être ajoutées que par unités de doubles-mots. C'est-à-dire qu'il est impossible de placer un octet seul sur la pile.

L'instruction PUSH insère un double-mot(19) sur la pile en ôtant 4 de ESP puis en stockant le double-mot en [ESP]. L'instruction POP lit le double-mot en [ESP] puis ajoute 4 à ESP. Le code ci-dessous montre comment fonctionnent ces instructions en supposant que ESP vaut initialement 1000H.

Sélectionnez
1 push dword 1 ; 1 est stocké en 0FFCh, ESP = 0FFCh

2 push dword 2 ; 2 est stocké en 0FF8h, ESP = 0FF8h

3 push dword 3 ; 3 est stocké en 0FF4h, ESP = 0FF4h

4 pop eax ; EAX = 3, ESP = 0FF8h

5 pop ebx ; EBX = 2, ESP = 0FFCh

6 pop ecx ; ECX = 1, ESP = 1000h

La pile peut être utilisée comme un endroit approprié pour stocker des données temporairement. Elle est également utilisée pour effectuer des appels de sous-programmes, passer des paramètres et des variables locales.

Le 80x86 fournit également une instruction, PUSHA, qui empile les valeurs des registres EAX, EBX, ECX, EDX, ESI, EDI et EBP (pas dans cet ordre).

L'instruction POPA peut être utilisée pour les dépiler tous.

V-D. Les Instructions CALL et RET

Le 80x86 fournit deux instructions qui utilisent la pile pour effectuer des appels de sous-programmes rapidement et facilement. L'instruction CALL effectue un saut inconditionnel vers un sous-programme et empile l'adresse de l'instruction suivante. L'instruction RET dépile une adresse et saute à cette adresse. Lors de l'utilisation de ces instructions, il est très important de gérer la pile correctement afin que le chiffre correct soit dépilé par l'instruction RET !

Le programme précédent peut être réécrit pour utiliser ces nouvelles instructions en changeant les lignes 25 à 34 en ce qui suit :

Sélectionnez
mov ebx, input1

call get_int

mov ebx, input2

call get_int

et en changeant le sous-programme get_int en :

Sélectionnez
get_int :

call read_int

mov [ebx], eax

ret

Il y a plusieurs avantages à utiliser CALL et RET :

  • c'est plus simple ! ;
  • cela permet d'imbriquer des appels de sous-programmes facilement.

Notez que get_int appelle read_int. Cet appel empile une autre adresse à la fin du code de read_int se trouve un RET qui dépile l'adresse de retour et saute vers le code de get_int. Puis, lorsque le RET de get_int est exécuté, il dépile l'adresse de retour qui revient vers asm_main. Cela fonctionne correctement, car il s'agit d'une pile LIFO.

Souvenez-vous, il est très important de dépiler toute donnée qui est empilée. Par exemple, considérons le code suivant :

Sélectionnez
1 get_int :

2 call read_int

3 mov [ebx], eax

4 push eax

5 ret ; dépile la valeur de EAX, pas l'adresse de retour !!

Ce code ne reviendra pas correctement !

V-E. Conventions d'appel

Lorsqu'un sous-programme est appelé, le code appelant et le sous-programme (l'appelé) doivent s'accorder sur la façon de se passer les données. Les langages de haut niveau ont des manières standard de passer les données appelées « conventions d'appel ». Pour interfacer du code de haut niveau avec le langage assembleur, le code assembleur doit utiliser les mêmes conventions que le langage de haut niveau. Les conventions d'appel peuvent différer d'un compilateur à l'autre ou peuvent varier selon la façon dont le code est compilé (p.e. selon que les optimisations sont activées ou pas). Une convention universelle est que le code est appelé par une instruction CALL et revient par un RET.

Tous les compilateurs C PC supportent une convention d'appel qui sera décrite dans le reste de ce chapitre par étape. Ces conventions permettent de créer des sous-programmes réentrants. Un sous-programme réentrant peut être appelé depuis n'importe quel endroit du programme en toute sécurité (même depuis le sous-programme lui-même).

V-E-1. Passer les paramètres via la pile

Les paramètres d'un sous-programme peuvent être passés par la pile. Ils sont empilés avant l'instruction CALL. Comme en C, si le paramètre doit être modifié par le sous-programme, l'adresse de la donnée doit être passée, pas sa valeur. Si la taille du paramètre est inférieure à un double-mot, il doit être converti en un double-mot avant d'être empilé.

Les paramètres sur la pile ne sont pas dépilés par le sous-programme, à place, ils sont accédés depuis la pile elle-même. Pourquoi ? Comme ils doivent être empilés avant l'instruction CALL, l'adresse de retour devrait être dépilée avant tout (puis réempilée).

Souvent, les paramètres sont utilisés à plusieurs endroits dans le sous-programme. Habituellement, ils ne peuvent pas être conservés dans un registre durant toute la durée du sous-programme et devront être stockés en mémoire. Les laisser sur la pile conserve une copie de la donnée en mémoire qui peut être accédée depuis n'importe quel endroit du sous-programme.

Considérons un sous-programme auquel on passe un paramètre unique via la pile. Lorsque le sous-programme est appelé, la pile ressemble à la Figure 4.1. On peut accéder au paramètre en utilisant l'adressage indirect ([ESP+4](20)).

Image non disponible

Lors de l'utilisation de l'adressage indirect, le processeur 80x86 accède à différents segments selon les registres utilisés dans l'expression d'adressage indirect. ESP (et EBP) utilisent le segment de pile alors que EAX, EBX, ECX et EDX utilisent le segment de données.
Cependant, ce n'est habituellement pas important pour la plupart des programmes en mode protégé, car pour eux, les segments de pile et de données sont les mêmes.

Si la pile est également utilisée dans le sous-programme pour stocker des données, le nombre à ajouter à ESP changera. Par exemple, la Figure 4.2 montre à quoi ressemble la pile si un DWORD est empilé.

Image non disponible

Maintenant, le paramètre se trouve en ESP + 8, plus en ESP + 4. Donc, utiliser ESP lorsque l'on fait référence à des paramètres peut être une source d'erreurs. Pour résoudre ce problème, le 80386 fournit un autre registre à utiliser : EBP. La seule utilité de ce registre est de faire référence à des données sur la pile. La convention d'appel C stipule qu'un sous-programme doit d'abord empiler la valeur de EBP puis définir EBP pour qu'il soit égal à ESP. Cela permet à ESP de changer au fur et à mesure que des données sont empilées ou dépilées sans modifier EBP. À la fin du programme, la valeur originale de EBP doit être restaurée (c'est pourquoi elle est sauvegardée au début du sous-programme).

La Figure 4.3 montre la forme générale d'un sous-programme qui suit ces conventions.

Image non disponible

Les lignes 2 et 3 de la Figure 4.3 constituent le prologue générique d'un sous-programme. Les lignes 5 et 6 constituent l'épilogue. La Figure 4.4 montre à quoi ressemble la pile immédiatement après le prologue.

Image non disponible

Maintenant, le paramètre peut être accédé avec [EBP + 8] depuis n'importe quel endroit du programme sans se soucier de ce qui a été empilé entretemps par le sous-programme.

Une fois le sous-programme terminé, les paramètres qui ont été empilés doivent être retirés. La convention d'appel C spécifie que c'est au code appelant de le faire. D'autres conventions sont différentes. Par exemple, la convention d'appel Pascal spécifie que c'est au sous-programme de retirer les paramètres (Il y a une autre forme de l'instruction RET qui permet de le faire facilement). Quelques compilateurs C supportent cette convention également.

Le mot-clé pascal est utilisé dans le prototype et la définition de la fonction pour indiquer au compilateur d'utiliser cette convention. En fait, la convention stdcall, que les fonctions de l'API C MS Windows utilisent, fonctionne également de cette façon. Quel est son avantage ? Elle est un petit peu plus efficace que la convention C. Pourquoi toutes les fonctions C n'utilisent-elles pas cette convention alors ? En général, le C autorise une fonction à avoir un nombre variable d'arguments (p.e., les fonctions printf et scanf).

Pour ce type de fonction, l'opération consistant à retirer les paramètres de la pile varie d'un appel à l'autre. La convention C permet aux instructions nécessaires à la réalisation de cette opération de varier facilement d'un appel à l'autre. Les conventions Pascal et stdcall rendent cette opération très compliquée. Donc, la convention Pascal (comme le langage Pascal) n'autorise pas ce type de fonction. MS Windows peut utiliser cette convention puisqu'aucune de ses fonctions d'API ne prend un nombre variable d'arguments.

La Figure 4.5 montre comment un sous-programme utilisant la convention d'appel C serait appelé.

Image non disponible

La ligne 3 retire le paramètre de la pile en manipulant directement le pointeur de pile. Une instruction POP pourrait également être utilisée, mais cela nécessiterait le stockage d'un paramètre inutile dans un registre. En fait, dans ce cas particulier, beaucoup de compilateurs utilisent une instruction POP ECX pour retirer le paramètre. Le compilateur utilise un POP plutôt qu'un ADD, car le ADD nécessite plus d'octets pour stocker l'instruction. Cependant, le POP change également la valeur de ECX ! Voici un autre programme exemple avec deux sous-programmes qui utilisent les conventions d'appel C dont nous venons de parler. La ligne 54 (et les autres) montre que plusieurs segments de données et de texte peuvent être déclarés dans un même fichier source. Ils seront combinés en des segments de données et de texte uniques lors de l'édition de liens. Diviser les données et le code en segments séparés permet d'avoir les données d'un sous-programme définies à proximité de celui-ci.

sub3.asm
Sélectionnez
1 %include "asm_io.inc"
2
3  segment .data
4 sum dd 0
5
6  segment .bss
7 input resd 1
8
9 ;
10 ; algorithme en pseudo-code
11 ; i = 1;
12 ; sum = 0;
13 ; while( get_int(i, &input), input != 0 ) {
14 ; sum += input;
15 ; i++;
16 ; }
17 ; print_sum(num);
18 segment .text
19 global _asm_main
20 _asm_main :
21 enter 0,0 ; routine d'initialisation
22 pusha
23
24 mov edx, 1 ; edx est le 'i' du pseudo-code
25 while_loop :
26 push edx ; empile i
27 push dword input ; empile l'adresse de input
28 call get_int
29 add esp, 8 ; dépile i et &input
30
31 mov eax, [input]
32 cmp eax, 0
33 je end_while
34
35 add [sum], eax ; sum += input
36
37 inc edx
38 jmp short while_loop
39
40 end_while:
41 push dword [sum] ; empile la valeur de sum
42 call print_sum
43 pop ecx ; dépile [sum]
44
45 popa
46 leave
47 ret
48
49 ; sous-programme get_int
50 ; Paramètres (dans l'ordre de l'empilement)
51 ; nombre de saisies (en [ebp + 12])
52 ; adresse du mot où stocker la saisie (en [ebp + 8])
53 ; Notes :
54 ; les valeurs de eax et ebx sont détruites
55 segment .data
56 prompt db ") Entrez un nombre entier (0 pour quitter): ", 0
57
58 segment .text
59 get_int:
60 push ebp
61 mov ebp, esp
62
63 mov eax, [ebp + 12]
64 call print_int
65
66 mov eax, prompt
67 call print_string
68
69 call read_int
70 mov ebx, [ebp + 8]
71 mov [ebx], eax ; stocke la saisie en mémoire
72
73 pop ebp
74 ret ; retour à l'appelant
75
76 ; sous-programme print_sum
77 ; affiche la somme
78 ; Paramètre :
79 ; somme à afficher (en [ebp+8])
80 ; Note : détruit la valeur de eax
81 ;
82 segment .data
83 result db "La somme vaut ", 0
84
85 segment .text
86 print_sum:
87 push ebp
88 mov ebp, esp
89
90 mov eax, result
91 call print_string
92
93 mov eax, [ebp+8]
94 call print_int
95 call print_nl
96
97 pop ebp
98 ret

V-E-2. Variables locales sur la pile

La pile peut être utilisée comme un endroit pratique pour stocker des variables locales. C'est exactement ce que fait le C pour les variables normales (ou automatiques en C lingo). Utiliser la pile pour les variables est important si l'on veut que les sous-programmes soient réentrants. Un programme réentrant fonctionnera qu'il soit appelé de n'importe quel endroit, même à partir du sous-programme lui-même. En d'autres termes, les sous-programmes réentrants peuvent être appelés récursivement.

Utiliser la pile pour les variables économise également de la mémoire. Les données qui ne sont pas stockées sur la pile utilisent de la mémoire du début à la fin du programme (le C appelle ce type de variables global ou static). Les données stockées sur la pile n'utilisent de la mémoire que lorsque le sous-programme dans lequel elles sont définies est actif.

Les variables locales sont stockées immédiatement après la valeur de EBP sauvegardée dans la pile. Elles sont allouées en soustrayant le nombre d'octets requis de ESP dans le prologue du sous-programme. La Figure 4.6 montre le nouveau squelette du sous-programme.

Image non disponible

Le registre EBP est utilisé pour accéder à des variables locales. Considérons la fonction C de la Figure 4.7.

Image non disponible

La Figure 4.8 montre comment le sous-programme équivalent pourrait être écrit en assembleur.

Image non disponible

La Figure 4.9 montre à quoi ressemble la pile après le prologue du programme de la Figure 4.8.

Image non disponible

Cette section de la pile qui contient les paramètres, les informations de retour et les variables locales est appelée cadre de pile (stack frame). Chaque appel de fonction C crée un nouveau cadre de pile sur la pile.

Le prologue et l'épilogue d'un sous-programme peuvent être simplifiés en utilisant deux instructions spéciales qui sont conçues spécialement dans ce but. L'instruction ENTER effectue le prologue et l'instruction LEAVE l'épilogue.

L'instruction ENTER prend deux opérandes immédiats. Dans la convention d'appel C, le deuxième opérande est toujours 0. Le premier opérande est le nombre d'octets nécessaires pour les variables locales. L'instruction LEAVE n'a pas d'opérande. La Figure 4.10 montre comment ces instructions sont utilisées. Notez que le squelette de programme (Figure 1.7) utilise également ENTER et LEAVE.

Image non disponible

En dépit du fait que ENTER et LEAVE simplifient le prologue et l'épilogue, ils ne sont pas utilisés très souvent. Pourquoi ? Parce qu'ils sont plus lents que les instructions plus simples équivalentes ! C'est un des exemples où il ne faut pas supposer qu'une instruction est plus rapide qu'une séquence de plusieurs instructions.

V-F. Programme multimodule

Un programme multimodule est un programme composé de plus d'un fichier objet. Tous les programmes présentés jusqu'ici sont des programmes multimodules. Ils consistent en un fichier objet C pilote et le fichier objet assembleur (plus les fichiers objet de la bibliothèque C). Souvenez-vous que l'éditeur de liens combine les fichiers objet en un programme exécutable unique. L'éditeur de liens doit rapprocher toutes les références faites à chaque étiquette d'un module (i.e. un fichier objet) de sa définition dans un autre module. Afin que le module A puisse utiliser une étiquette définie dans le module B, la directive extern doit être utilisée. Après la directive extern vient une liste d'étiquettes délimitées par des virgules. La directive indique à l'assembleur de traiter ces étiquettes comme externes au module. C'est-à-dire qu'il s'agit d'étiquettes qui peuvent être utilisées dans ce module, mais sont définies dans un autre. Le fichier asm_io.inc définit les routines read_int, etc. comme externes.

En assembleur, les étiquettes ne peuvent pas être accédées de l'extérieur par défaut. Si une étiquette doit pouvoir être accédée depuis d'autres modules que celui dans lequel elle est dénie, elle doit être déclarée comme globale dans son module, par le biais de la directive global. La ligne 13 du listing du programme squelette de la Figure 1.7 montre que l'étiquette _asm_main est définie comme globale. Sans cette déclaration, l'éditeur de liens indiquerait une erreur. Pourquoi ? Parce que le code C ne pourrait pas faire référence à l'étiquette interne _asm_main.

Voici le code de l'exemple précédent réécrit afin d'utiliser deux modules. Les deux sous-programmes (get_int et print_sum) sont dans des fichiers source distincts de celui de la routine _asm_main.

main4.asm
Sélectionnez
1 %include "asm_io.inc"
2
3 segment .data
4 sum dd 0
5
6 segment .bss
7 input resd 1
8
9 segment .text
10 global _asm_main
11 extern get_int, print_sum
12 _asm_main :
13 enter 0,0 ; routine d'initialisation
14 pusha
15
16 mov edx, 1 ; edx est le 'i' du pseudo-code
17 while_loop :
18 push edx ; empile i
19 push dword input ; empile l'adresse de input
20 call get_int
21 add esp, 8 ; dépile i et &input
22
23 mov eax, [input]
24 cmp eax, 0
25 je end_while
26
27 add [sum], eax ; sum += input
28
29 inc edx
30 jmp short while_loop
31
32 end_while:
33 push dword [sum] ; empile la valeur de sum
34 call print_sum
35 pop ecx ; dépile [sum]
36
37 popa
38 leave
39 ret
sub4.asm
Sélectionnez
1 %include "asm_io.inc"
2
3  segment .data
4 prompt db ") Entrez un nombre entier (0 pour quitter): ", 0
5
6 segment .text
7 global get_int, print_sum
8 get_int :
9 enter 0,0
10
11 mov eax, [ebp + 12]
12 call print_int
13
14 mov eax, prompt
15 call print_string
16
17 call read_int
18 mov ebx, [ebp + 8]
19 mov [ebx], eax ; stocke la saisie en mémoire
20
21 leave
22 ret ; retour à l'appelant
23
24 segment .data
25 result db "La somme vaut ", 0
26
27 segment .text
28 print_sum :
29 enter 0,0
30
31 mov eax, result
32 call print_string
33
34 mov eax, [ebp+8]
35 call print_int
36 call print_nl
37
38 leave
39 ret

L'exemple ci-dessus n'a que des étiquettes de code globales ; cependant, les étiquettes de donnée globales fonctionnent exactement de la même façon.

V-G. Interfacer de l'assembleur avec du C

Aujourd'hui, très peu de programmes sont écrits complètement en assembleur.

Les compilateurs sont très performants dans la conversion de code de haut niveau en code machine efficace. Comme il est plus facile d'écrire du code dans un langage de haut niveau, ils sont plus populaires. De plus, le code de haut niveau est beaucoup plus portable que l'assembleur !

Lorsque de l'assembleur est utilisé, c'est souvent pour de petites parties du code. Cela peut être fait de deux façons : en appelant des sous-routines assembleur depuis le C ou en incluant de l'assembleur. Inclure de l'assembleur permet au programmeur de placer des instructions assembleur directement dans le code C. Cela peut être très pratique ; cependant, il y a des inconvénients à inclure l'assembleur. Le code assembleur doit être écrit dans le format que le compilateur utilise. Aucun compilateur pour le moment ne supporte le format NASM. Des compilateurs différents demandent des formats différents. Borland et Microsoft demandent le format MASM. DJGPP et gcc sous Linux demandent le format GAS(21). La technique d'appel d'une sous-routine assembleur est beaucoup plus standardisée sur les PC.

Les routines assembleur sont habituellement utilisées avec le C pour les raisons suivantes :

  • un accès direct aux fonctionnalités matérielles de l'ordinateur est nécessaire, car il est difficile ou impossible d'y accéder en C ;
  • la routine doit être la plus rapide possible et le programmeur peut optimiser le code à la main mieux que le compilateur.

La dernière raison n'est plus aussi valide qu'elle l'était. La technologie des compilateurs a été améliorée au fil des ans et les compilateurs génèrent souvent un code très performant (en particulier si les optimisations sont activées).

Les inconvénients des routines assembleur sont une portabilité et une lisibilité réduites.

La plus grande partie des conventions d'appel C a déjà été présentée.

Cependant, il y a quelques fonctionnalités supplémentaires qui doivent être décrites.

V-G-1. Sauvegarder les registres▲

Tout d'abord, Le C suppose qu'une sous-routine maintient les valeurs des registres suivants : EBX, ESI, EDI, EBP, CS, DS, SS, ES. Cela ne signifie pas que la sous-routine ne les change pas en interne. Cela signifie que si elle change leurs valeurs, elle doit les restaurer avant de revenir. Les valeurs de EBX, ESI et EDI doivent être inchangées, car le C utilise ces registres pour les variables de registre. Habituellement, la pile est utilisée pour sauvegarder les valeurs originales de ces registres.

Le mot-clé register peut être utilisé dans une déclaration de variable C pour suggérer au compilateur d'utiliser un registre pour cette variable plutôt qu'un emplacement mémoire. On appelle ces variables, variables de registre. Les compilateurs modernes le font automatiquement sans qu'il y ait besoin d'une suggestion.

V-G-2. Étiquettes de fonctions

La plupart des compilateurs C ajoutent un caractère underscore(_) au début des noms des fonctions et des variables global/static. Par exemple, à une fonction appelée f sera assignée l'étiquette _f. Donc, s'il s'agit d'une routine assembleur, elle doit être étiquetée _f, pas f. Le compilateur Linux gcc, n'ajoute aucun caractère. Dans un exécutable Linux ELF, on utiliserait simplement l'étiquette f pour la fonction f. Cependant, le gcc de DJGPP ajoute un underscore. Notez que dans le squelette de programme assembleur (Figure 1.7), l'étiquette de la routine principale est _asm_main.

V-G-3. Passer des paramètres

Dans les conventions d'appel C, les arguments d'une fonction sont empilés sur la pile dans l'ordre inverse de celui dans lequel ils apparaissent dans l'appel de la fonction.

Considérons l'expression C suivante : printf("x = %d\n",x); la Figure 4.11 montre comment elle serait compilée (dans le format NASM équivalent).

Image non disponible

La Figure 4.12 montre à quoi ressemble la pile après le prologue de la fonction printf.

Image non disponible

La fonction printf est une des fonctions de la bibliothèque C qui peut prendre n'importe quel nombre d'arguments. Les règles des conventions d'appel C ont été spécialement écrites pour autoriser ce type de fonctions. Comme l'adresse de la chaîne format est empilée en dernier, son emplacement sur la pile sera toujours EBP + 8 quel que soit le nombre de paramètres passés à la fonction. Le code de printf peut alors analyser la chaîne format pour déterminer combien de paramètres ont dû être passés et les récupérer sur la pile.

Bien sûr, s'il y a une erreur, printf("x = %d\n"), le code de printf affichera quand même la valeur double-mot en [EBP + 12]. Cependant, ce ne sera pas la valeur de x !

Il n'est pas nécessaire d'utiliser l'assembleur pour gérer un nombre aléatoire d'arguments en C. L'en-tête stdarg.h définit des macros qui peuvent être utilisées pour l'effectuer de façon portable. Voyez n'importe quel bon livre sur le C pour plus de détails.

V-G-4. Calculer les adresses des variables locales

Trouver l'adresse d'une étiquette définie dans les segments data ou bss est simple. Basiquement, l'éditeur de liens le fait. Cependant, calculer l'adresse d'une variable locale (ou d'un paramètre) sur la pile n'est pas aussi intuitif.

Néanmoins, c'est un besoin très courant lors de l'appel de sous-routines.

Considérons le cas du passage de l'adresse d'une variable (appelons-la x) à une fonction (appelons-la foo). Si x est situé en EBP − 8 sur la pile, on ne peut pas utiliser simplement :

Sélectionnez
mov eax, ebp – 8

Pourquoi ? La valeur que MOV stocke dans EAX doit être calculée par l'assembleur (c'est-à-dire qu'elle doit donner une constante). Cependant, il y a une instruction qui effectue le calcul désiré. Elle est appelée LEA (pour Load Eective Address, Charger l'Adresse Effective). L'extrait suivant calculerait l'adresse de x et la stockerait dans EAX :

Sélectionnez
lea eax, [ebp - 8]

Maintenant, EAX contient l'adresse de x et peut être placé sur la pile lors de l'appel de la fonction foo. Ne vous méprenez pas, au niveau de la syntaxe, c'est comme si cette instruction lisait la donnée en [EBP−8]; cependant, ce n'est pas vrai. L'instruction LEA ne lit jamais la mémoire ! Elle calcule simplement l'adresse qui sera lue par une autre instruction et stocke cette adresse dans son premier opérande registre. Comme elle ne lit pas la mémoire, aucune taille mémoire (p.e. dword) n'est nécessaire ni autorisée.

V-G-5. Retourner des valeurs

Les fonctions C non void retournent une valeur. Les conventions d'appel C spécifient comment cela doit être fait. Les valeurs de retour sont passées via les registres. Tous les types entiers (char, int, enum, etc.) sont retournés dans le registre EAX. S'ils sont plus petits que 32 bits, ils sont étendus à 32 bits lors du stockage dans EAX (la façon dont ils sont étendus dépend du fait qu'ils sont signés ou non). Les valeurs 64 bits sont retournées dans la paire de registres EDX:EAX. Les valeurs de pointeurs sont également stockées dans EAX. Les valeurs en virgule flottante sont stockées dans le registre STP du coprocesseur arithmétique (ce registre est décrit dans le

chapitre sur les nombres en virgule flottante).

V-G-6. Autres conventions d'appel

Les règles ci-dessus décrivent les conventions d'appel C supportées par tous les compilateurs C 80x86. Souvent, les compilateurs supportent également d'autres conventions d'appel. Lorsqu'il y a une interface avec le langage assembleur, il est très important de connaître les conventions utilisées par le compilateur lorsqu'il appelle votre fonction. Habituellement, par défaut, ce sont les conventions d'appel standard qui sont utilisées, cependant, ce n'est pas toujours le cas(22). Les compilateurs qui utilisent plusieurs conventions ont souvent des options de ligne de commande qui peuvent être utilisées pour changer la convention par défaut. Ils fournissent également des extensions à la syntaxe C pour assigner explicitement des conventions d'appel à des fonctions de manière individuelle. Cependant, ces extensions ne sont pas standardisées et peuvent varier d'un compilateur à l'autre.

Le compilateur GCC autorise différentes conventions d'appel. La convention utilisée par une fonction peut être déclarée explicitement en utilisant l'extension __attribute__ . Par exemple, pour déclarer une fonction void qui utilise la convention d'appel standard appelée f qui ne prend qu'un paramètre int, utilisez la syntaxe suivante pour son prototype :

Sélectionnez
void f ( int ) __attribute__((cdecl));

GCC supporte également la convention d'appel standard call . La fonction ci-dessus pourrait être déclarée afin d'utiliser cette convention en remplaçant le cdecl par stdcall. La différence entre stdcall et cdecl est que stdcall impose à la sous-routine de retirer les paramètres de la pile (comme le fait la convention d'appel Pascal). Donc, la convention stdcall ne peut être utilisée que par des fonctions qui prennent un nombre fixe d'arguments (i.e. celles qui ne sont pas comme printf et scanf).

GCC supporte également un attribut supplémentaire appelé regparm qui indique au compilateur d'utiliser les registres pour passer jusqu'à trois arguments entiers à une fonction au lieu d'utiliser la pile. C'est un type d'optimisation courant que beaucoup de compilateurs supportent.

Borland et Microsoft utilisent une syntaxe commune pour déclarer les conventions d'appel. Ils ajoutent les mots-clés __cdecl et __stdcall au C. Ces mots-clés se comportent comme des modicateurs de fonction et apparaissent immédiatement avant le nom de la fonction dans un prototype.

Par exemple, la fonction f ci-dessus serait définie comme suit par Borland et Microsoft :

Sélectionnez
void __cdecl f( int );

Il y a des avantages et des inconvénients à chacune des conventions d'appel.

Le principal avantage de cdecl est qu'elle est simple et très flexible.

Elle peut être utilisée pour n'importe quel type de fonction C et sur n'importe quel compilateur C. Utiliser d'autres conventions peut limiter la portabilité de la sous-routine. Son principal inconvénient est qu'elle peut être plus lente que certaines autres et utilise plus de mémoire (puisque chaque appel de fonction nécessite du code pour retirer les paramètres de la pile).

L'avantage de la convention stdcall est qu'elle utilise moins de mémoire que cdecl. Aucun nettoyage de pile n'est requis après l'instruction CALL. Son principal inconvénient est qu'elle ne peut pas être utilisée avec des fonctions qui ont un nombre variable d'arguments.

L'avantage d'utiliser une convention qui se sert des registres pour passer des paramètres entiers est la rapidité. Le principal inconvénient est que la convention est plus complexe. Certains paramètres peuvent se trouver dans des registres et d'autres sur la pile.

V-G-7. Exemples

Voici un exemple qui montre comment une routine assembleur peut être interfacée avec un programme C (notez que ce programme n'utilise pas le programme assembleur squelette (Figure 1.7) ni le module driver.c).

main5.c
Sélectionnez
1 #include <stdio.h>
2 / prototype de la routine assembleur /
3 void calc_sum( int, int ) __attribute__((cdecl));
4
5 int main( void )
6 {
7 int n, sum;
8
9 printf ("Somme des entiers jusquà ' : ");
10 scanf("%d", &n);
11 calc_sum(n, &sum);
12 printf ("La somme vaut %d\n", sum);
13 return 0;
14 }
sub5.asm
Sélectionnez
1 ; sous-routine _calc_sum
2 ; trouve la somme des entiers de 1 à n
3 ; Paramètres :
4 ; n - jusqu'où faire la somme (en [ebp + 8])
5 ; sump - pointeur vers un entier dans lequel stocker la somme (en [ebp + 12])
6 ; pseudo-code C :
7 ; void calc_sum( int n, int * sump )
8 ; {
9 ; int i, sum = 0;
10 ; for( i=1; i <= n; i++ )
11 ; sum += i;
12 ; *sump = sum;
13 ; }
14
15 segment .text
16 global _calc_sum
17 ;
18 ; variable locale :
19 ; sum en [ebp-4]
20 _calc_sum :
21 enter 4,0 ; Fait de la place pour sum sur la pile
22 push ebx ; IMPORTANT !
23
24 mov dword [ebp-4],0 ; sum = 0
25 dump_stack 1, 2, 4 ; affiche la pile de ebp-8 à ebp+16
26 mov ecx, 1 ; ecx est le i du pseudocode
27 for_loop:
28 cmp ecx, [ebp+8] ; cmp i et n
29 jnle end_for ; si non i <= n, quitter
30
31 add [ebp-4], ecx ; sum += i
32 inc ecx
33 jmp short for_loop
34
35 end_for:
36 mov ebx, [ebp+12] ; ebx = sump
37 mov eax, [ebp-4] ; eax = sum
38 mov [ebx], eax
39
40 pop ebx ; restaure ebx
41 leave
42 ret

Pourquoi la ligne 22 de sub5.asm est si importante ? Parce que les conventions d'appel C imposent que la valeur de EBX ne soit pas modifiée par l'appel de fonction. Si ce n'est pas respecté, il est très probable que le programme ne fonctionne pas correctement.

La ligne 25 montre la façon dont fonctionne la macro dump_stack. Souvenez-vous que le premier paramètre est juste une étiquette numérique et les deuxième et troisième paramètres déterminent respectivement combien de doubles-mots elle doit afficher en dessous et au-dessus de EBP. La Figure 4.13 montre une exécution possible du programme.

Image non disponible

Pour cette capture, on peut voir que l'adresse du dword où stocker la somme est BFFFFB80 (en EBP + 12); le nombre jusqu'auquel additionner est 0000000A (en EBP + 8), l'adresse de retour pour la routine est 08048501 (en EBP + 4), la valeur sauvegardée de EBP est BFFFFB88 (en EBP), la valeur de la variable locale est 0 en (EBP - 4) et pour finir, la valeur sauvegardée de EBX est 4010648C (en EBP - 8).

La fonction calc_sum pourrait être réécrite pour retourner la somme plutôt que d'utiliser un pointeur. Comme la somme est une valeur entière, elle doit être placée dans le registre EAX. La ligne 11 du fichier main5.c deviendrait :

Sélectionnez
sum = calc_sum(n);

De plus, le prototype de calc_sum devrait être altéré. Voici le code assembleur modifié :

sub6.asm
Sélectionnez
1 ; sous-routine _calc_sum
2 ; trouve la somme des entiers de 1 à n
3 ; Paramètres :
4 ; n - jusqu'où faire la somme (en [ebp + 8])
5 ; Valeur de retour :
6 ; valeur de la somme
7 ; pseudo-code C :
8 ; int calc_sum( int n )
9 ; {
10 ; int i, sum = 0;
11 ; for( i=1; i <= n; i++ )
12 ; sum += i;
13 ; return sum;
14 ; }
15 segment .text
16 global _calc_sum
17 ;
18 ; variable locale :
19 ; sum en [ebp-4]
20 _calc_sum :
21 enter 4,0 ; fait de la place pour la somme sur la pile
22
23 mov dword [ebp-4],0 ; sum = 0
24 mov ecx, 1 ; ecx est le i du pseudocode
25 for_loop:
26 cmp ecx, [ebp+8] ; cmp i et n
27 jnle end_for ; si non i <= n, quitter
28
29 add [ebp-4], ecx ; sum += i
30 inc ecx
31 jmp short for_loop
32
33 end_for:
34 mov eax, [ebp-4] ; eax = sum
35
36 leave
37 ret

V-G-8. Appeler des fonctions C depuis l'assembleur

Un des avantages majeurs d'interfacer le C et l'assembleur est que cela permet au code assembleur d'accéder à la grande bibliothèque C et aux fonctions utilisateur. Par exemple, si l'on veut appeler la fonction scanf pour lire un entier depuis le clavier. La Figure 4.14 montre comment le faire.

Image non disponible

Une chose très importante à se rappeler est que scanf suit les conventions d'appel C standard à la lettre. Cela signifie qu'elle préserve les valeurs des registres EBX, ESI et EDI, cependant, les registres EAX, ECX et EDX peuvent être modifiés ! En fait, EAX sera modifié, car il contiendra la valeur de retour de l'appel à scanf. Pour d'autres exemples d'interface entre l'assembleur et le C, observez le code dans asm_io.asm qui a été utilisé pour créer asm_io.obj.

V-H. Sous-programmes réentrants et récursifs

Un sous-programme réentrant remplit les critères suivants :

  • il ne doit pas modifier son code. Dans un langage de haut niveau, cela serait difficile, mais en assembleur, il n'est pas si dur que cela pour un programme de modifier son propre code. Par exemple :
Sélectionnez
mov word [cs :$+7], 5 ; copie 5 dans le mot 7 octets plus loin

add ax, 2 ; l'expression précédente change 2 en 5 !
  • Il ne doit pas modifier de données globales (comme celles qui se trouvent dans les segments data et bss). Toutes les variables sont stockées sur la pile.

Il y a plusieurs avantages à écrire du code réentrant :

  • un sous-programme réentrant peut être appelé récursivement ;
  • un programme réentrant peut être partagé par plusieurs processus ;
  • sur beaucoup de systèmes d'exploitation multitâches, s'il y a plusieurs instances d'un programme en cours, seule une copie du code se trouve en mémoire. Les bibliothèques partagées et les DLL (Dynamic Link Libraries, Bibliothèques de Lien Dynamique) utilisent le même principe ;
  • les sous-programmes réentrants fonctionnent beaucoup mieux dans les programmes multithreadés(23). Windows 9x/NT et la plupart des systèmes d'exploitation de style Unix (Solaris, Linux, etc.) supportent les programmes multithreadés.

V-H-1. Sous-programmes récursifs

Ce type de sous-programmes s'appellent eux-mêmes. La récursivité peut être soit directe soit indirecte. La récursivité directe survient lorsqu'un sous-programme, disons foo, s'appelle lui-même dans le corps de foo. La récursivité indirecte survient lorsqu'un sous-programme ne s'appelle pas directement lui-même, mais via un autre sous-programme qu'il appelle. Par exemple, le sous-programme foo pourrait appeler bar et bar pourrait appeler foo.

Les sous-programmes récursifs doivent avoir une condition de terminaison.

Lorsque cette condition est vraie, il n'y a plus d'appel récursif. Si une routine récursive n'a pas de condition de terminaison ou que la condition n'est jamais remplie, la récursivité ne s'arrêtera jamais (exactement comme une boucle infinie).

La Figure 4.15 montre une fonction qui calcule une factorielle récursivement.

Elle peut être appelée depuis le C avec :

Sélectionnez
x = fact (3); / trouve 3! /
Image non disponible

La Figure 4.16 montre à quoi ressemble la pile au point le plus profond pour l'appel de fonction ci-dessus.

Image non disponible

Les Figures 4.17 et 4.18 montrent un exemple récursif plus compliqué en C et en assembleur, respectivement.

Image non disponible
Image non disponible

Quelle est la sortie pour f(3) ? Notez que l'instruction ENTER crée un nouveau i sur la pile pour chaque appel récursif.

Donc, chaque instance récursive de f a sa propre variable i indépendante.

Définir i comme un double-mot dans le segment data ne fonctionnerait pas pareil.

V-H-2. Révision des types de stockage des variables en C

Le C fournit plusieurs types de stockage des variables :

global : ces variables sont déclarées en dehors de toute fonction et sont stockées à des emplacements mémoire fixes (dans les segments data ou bss) et existent depuis le début du programme jusqu'à la fin. Par défaut, on peut y accéder de n'importe quelle fonction dans le programme, cependant, si elles sont déclarées comme static, seules les fonctions dans le même module peuvent y accéder (i.e. en termes assembleur, l'étiquette est interne, pas externe) ;

static : il s'agit des variables locales d'une fonction qui sont déclarées static (malheureusement, le C utilise le mot-clé static avec deux sens différents !). Ces variables sont également stockées dans des emplacements mémoire fixes (dans data ou bss), mais ne peuvent être accédées directement que dans la fonction où elles sont définies ;

automatic : c'est le type par défaut d'une variable C dénie dans une fonction. Ces variables sont allouées sur la pile lorsque la fonction dans laquelle elles sont définies est appelée et sont désallouées lorsque la fonction revient. Donc, elles n'ont pas d'emplacement mémoire fixe.

register ; ce mot-clé demande au compilateur d'utiliser un registre pour la donnée dans cette variable. Il ne s'agit que d'une requête. Le compilateur n'a pas à l'honorer. Si l'adresse de la variable est utilisée à un endroit quelconque du programme, elle ne sera pas respectée (puisque les registres n'ont pas d'adresse). De plus, seuls les types entiers simples peuvent être des valeurs registres. Les types structures ne peuvent pas l'être : ils ne tiendraient pas dans un registre ! Les compilateurs C placent souvent les variables automatiques normales dans des registres sans aide du programmeur.

volatile : ce mot-clé indique au compilateur que la valeur de la variable peut changer à tout moment. Cela signifie que le compilateur ne peut faire aucune supposition sur le moment où la variable est modifiée. Souvent, un compilateur stocke la valeur d'une variable dans un registre temporairement et utilise le registre à la place de la variable dans une section de code. Il ne peut pas faire ce type d'optimisations avec les variables volatiles. Un exemple courant de variable volatile serait une variable qui peut être modifiée par deux threads d'un programme multithreadé. Considérons le code suivant :

Sélectionnez
1 x = 10;

2 y = 20;

3 z = x;

Si x pouvait être altéré par un autre thread, il est possible que l'autre thread change x entre les lignes 1 et 3, alors z ne vaudrait pas 10.

Cependant, si x n'a pas été déclaré comme volatile, le compilateur peut supposer que x est inchangé et positionner z à 10.

Une autre utilisation de volatile est d'empêcher le compilateur d'utiliser un registre pour une variable.

VI. Tableaux

VI-A. Introduction

Un tableau est un bloc contigu de données en mémoire. Tous les éléments de la liste doivent être du même type et occuper exactement le même nombre d'octets en mémoire. En raison de ces propriétés, les tableaux permettent un accès efficace à une donnée par sa position (ou indice) dans le tableau.

L'adresse de n'importe quel élément peut être calculée en connaissant les trois choses suivantes :

  • l'adresse du premier élément du tableau ;
  • le nombre d'octets de chaque élément ;
  • l'indice de l'élément.

Il est pratique de considérer l'indice du premier élément du tableau comme étant 0 (comme en C). Il est possible d'utiliser d'autres valeurs pour le premier indice, mais cela complique les calculs.

VI-A-1. Définir des tableaux

VI-A-1-a. Définir des tableaux dans les segments data et bss

Pour définir un tableau initialisé dans le segment data, utilisez les directives db, dw, etc. normales. directives. NASM fournit également une directive utile appelée TIMES qui peut être utilisée pour répéter une expression de nombreuses fois sans avoir à la dupliquer à la main. La Figure 5.1 montre plusieurs exemples.

Image non disponible

Pour définir un tableau non initialisé dans le segment bss, utilisez les directives resb, resw, etc. Souvenez-vous que ces directives ont un opérande qui spécifie le nombre d'unités mémoire à réserver. La Figure 5.1 montre également des exemples de ces types de définitions.

VI-A-1-b. Définir des tableaux comme variables locales

Il n'y a pas de manière directe de définir un tableau comme variable locale sur la pile. Comme précédemment, on calcule le nombre total d'octets requis pour toutes les variables locales, y compris les tableaux, et on l'ôte de ESP (soit directement, soit en utilisant l'instruction ENTER). Par exemple, si une fonction a besoin d'une variable caractère, deux entiers double-mot et un tableau de 50 éléments d'un mot, il faudrait 1+2×4+50×2 = 109 octets.

Cependant, le nombre ôté de ESP doit être un multiple de quatre (112 dans ce cas) pour maintenir ESP sur un multiple de double-mot. On peut organiser les variables dans ces 109 octets de plusieurs façons. La Figure 5.2 montre deux manières possibles.

Image non disponible

La partie inutilisée sert à maintenir les doubles-mots sur des adresses multiples de doubles-mots afin d'accélérer les accès mémoire.

VI-A-2. Accéder aux éléments de tableaux

Il n'y a pas d'opérateur [ ] en langage assembleur comme en C. Pour accéder à un élément d'un tableau, son adresse doit être calculée. Considérons les deux définitions de tableau suivantes :

Sélectionnez
array1 db 5, 4, 3, 2, 1 ; tableau d'octets

array2 dw 5, 4, 3, 2, 1 ; tableau de mots

Voici quelques exemples utilisant ces tableaux :

Sélectionnez
1 mov al, [array1] ; al = array1[0]

2 mov al, [array1 + 1] ; al = array1[1]

3 mov [array1 + 3], al ; array1[3] = al

4 mov ax, [array2] ; ax = array2[0]

5 mov ax, [array2 + 2] ; ax = array2[1] (PAS array2[2]!)

6 mov [array2 + 6], ax ; array2[3] = ax

7 mov ax, [array2 + 1] ; ax = ??

À la ligne 5, l'élément 1 du tableau de mots est référencé, pas l'élément 2.

Pourquoi ? Les mots sont des unités de deux octets, donc pour se déplacer à l'élément suivant d'un tableau de mots, on doit se déplacer de deux octets, pas d'un seul.

La ligne 7 lit un octet du premier élément et un octet du suivant. En C, le compilateur regarde le type de pointeur pour déterminer de combien d'octets il doit se déplacer dans une expression utilisant l'arithmétique des pointeurs, afin que le programmeur n'ait pas à le faire.

Cependant, en assembleur, c'est au programmeur de prendre en compte la taille des éléments du tableau lorsqu'il se déplace parmi les éléments.

La Figure 5.3 montre un extrait de code qui additionne tous les éléments de array1 de l'exemple de code précédent.

Image non disponible

À la ligne 7, AX est ajouté à DX. Pourquoi pas AL ? Premièrement, les deux opérandes de l'instruction ADD doivent avoir la même taille. Deuxièmement, il serait facile d'ajouter des octets et d'obtenir une somme qui serait trop grande pour tenir sur un octet. En utilisant DX, la somme peut atteindre 65 535. Cependant, il est important de réaliser que AH est également additionné. C'est pourquoi, AH est positionné à zéro(24) à la ligne 3.

Les Figures 5.4 et 5.5 montrent deux manières alternatives de calculer la somme. Les lignes en italiques remplacent les lignes 6 et 7 de la Figure 5.3.

Image non disponible
Image non disponible

VI-A-3. Adressage indirect plus avancé

Ce n'est pas étonnant, l'adressage indirect est souvent utilisé avec les tableaux. La forme la plus générale d'une référence mémoire indirecte est :

Sélectionnez
[ reg de base + facteur *reg d'index + constante ]

où :

reg de base est un des registres EAX, EBX, ECX, EDX, EBP, ESP, ESI ou EDI ;

facteur est 1, 2, 4 ou 8 (s'il vaut 1, le facteur est omis) ;

reg d'index est un des registres EAX, EBX, ECX, EDX, EBP, ESI, EDI (notez que ESP n'est pas dans la liste) ;

constante est une constante 32 bits. Cela peut être une étiquette (ou une expression d'étiquette).

VI-A-4. Exemple

Voici un exemple qui utilise un tableau et le passe à une fonction. Il utilise le programme array1c.c (dont le listing suit) comme pilote, pas le programme driver.c.

array1.asm
Sélectionnez
1 ?fine ARRAY_SIZE 100
2 ?fine NEW_LINE 10
3
4 segment .data
5 FirstMsg db "10 premiers éléments du tableau", 0
6 Prompt db "Entrez l'indice de l'élément à afficher : ", 0
7 SecondMsg db "L'élément %d vaut %d", NEW_LINE, 0
8 ThirdMsg db "Éléments 20 à 29 du tableau", 0
9 InputFormat db "%d", 0
10
11 segment .bss
12 array resd ARRAY_SIZE
13
14 segment .text
15 extern _puts, _printf, _scanf, _dump_line
16 global _asm_main
17 _asm_main :
18 enter 4,0 ; variable locale dword en EBP – 4
19 push ebx
20 push esi
21
22 ; initialise le tableau à 100, 99, 98, 97...
23
24 mov ecx, ARRAY_SIZE
25 mov ebx, array
26 init_loop:
27 mov [ebx], ecx
28 add ebx, 4
29 loop init_loop
30
31 push dword FirstMsg ; affiche FirstMsg
32 call _puts
33 pop ecx
34
35 push dword 10
36 push dword array
37 call _print_array ; affiche les 10 premiers éléments du tableau
38 add esp, 8
39
40 ; demande à l'utilisateur l'indice de l'élément
41 Prompt_loop:
42 push dword Prompt
43 call _printf
44 pop ecx
45
46 lea eax, [ebp-4] ; eax = adresse du dword local
47 push eax
48 push dword InputFormat
49 call _scanf
50 add esp, 8
51 cmp eax, 1 ; eax = valeur de retour de scanf
52 je InputOK
53
54 call _dump_line ; ignore le reste de la ligne et recommence
55 jmp Prompt_loop ; si la saisie est invalide
56
57 InputOK:
58 mov esi, [ebp-4]
59 push dword [array + 4*esi]
60 push esi
61 push dword SecondMsg ; affiche la valeur de l'élément
62 call _printf
63 add esp, 12
64
65 push dword ThirdMsg ; affiche les éléments 20 à 29
66 call _puts
67 pop ecx
68
69 push dword 10
70 push dword array + 20*4 ; adresse de array[20]
71 call _print_array
72 add esp, 8
73
74 pop esi
75 pop ebx
76 mov eax, 0 ; retour au C
77 leave
78 ret
79
80 ;
81 ; routine _print_array
82 ; Routine appelable depuis le C qui affiche les éléments d'un tableau de doubles mots
83 ; comme des entiers signés.
84 ; Prototype C :
85 ; void print_array( const int * a, int n);
86 ; Paramètres :
87 ; a - pointeur vers le tableau à afficher (en ebp+8 sur la pile)
88 ; n - nombre d'entiers à afficher (en ebp+12 sur la pile)
89
90 segment .data
91 OutputFormat db "%-5d ]", NEW_LINE, 0
92
93 segment .text
94 global _print_array
95 _print_array :
96 enter 0,0
97 push esi
98 push ebx
99
100 xor esi, esi ; esi = 0
101 mov ecx, [ebp+12] ; ecx = n
102 mov ebx, [ebp+8] ; ebx = adresse du tableau
103 print_loop :
104 push ecx ; printf change ecx !
105
106 push dword [ebx + 4*esi] ; empile tableau[esi]
107 push esi
108 push dword OutputFormat
109 call _printf
110 add esp, 12 ; retire les paramètres (laisse ecx !)
111
112 inc esi
113 pop ecx
114 loop print_loop
115
116 pop ebx
117 pop esi
118 leave
119 ret
array1c.c
Sélectionnez
1 #include <stdio.h>
2
3  int asm_main( void );
4 void dump_line( void );
5
6 int main()
7 {
8 int ret_status ;
9 ret_status = asm_main();
10 return ret_status ;
11 }
12
13 /
14 fonction dump_line
15 retire tous les caractères restant sur la ligne courante dans le buffer d'entreé
16 /
17 void dump_line()
18 {
19 int ch;
20
21 while( (ch = getchar()) != EOF && ch != '\n')
22 / null body/ ;
23 }

VI-A-4-a. L'instruction LEA revisitée

L'instruction LEA peut être utilisée dans d'autres cas que le calcul d'adresse.

Elle est assez couramment utilisée pour les calculs rapides. Considérons le code suivant :

Sélectionnez
lea ebx, [4*eax + eax]

Il stocke la valeur de 5×EAX dans EBX. Utiliser LEA dans ce cas est à la fois plus simple et plus rapide que d'utiliser MUL. Cependant, il faut être conscient du fait que l'expression entre crochets doit être une adresse indirecte légale.

Donc, par exemple, cette instruction ne peut pas être utilisée pour multiplier par 6 rapidement.

VI-A-5. Tableaux Multidimensionnels

Les tableaux multidimensionnels ne sont pas vraiment différents de ceux à une dimension dont nous avons déjà parlé. En fait, ils sont représentés en mémoire exactement comme cela, un tableau à une dimension.

VI-A-5-a. Tableaux à Deux Dimensions

Ce n'est pas étonnant, le tableau multidimensionnel le plus simple est celui à deux dimensions. Un tableau à deux dimensions est souvent représenté comme une grille d'éléments. Chaque élément est identifié par une paire d'indices. Par convention, le premier indice est associé à la ligne et le second à la colonne.

Considérons un tableau avec trois lignes et deux colonnes défini de la manière suivante :

Sélectionnez
int a [3][2];

Le compilateur C réserverait de la place pour un tableau d'entiers de 6 (= 2 × 3) et placerait les éléments comme suit :

Image non disponible

Ce que ce tableau tente de montrer et que l'élément référencé comme a[0][0] est stocké au début du tableau de 6 éléments à une dimension. L'élément a[0][1] est stocké à la position suivante (indice 1), etc. Chaque ligne du tableau à deux dimensions est stockée en mémoire de façon contigüe. Le dernier élément d'une ligne est suivi par le premier élément de la suivante. On appelle cela la représentation au niveau ligne (rowwise) du tableau et c'est comme cela qu'un compilateur C/C++ représenterait le tableau.

Comment le compilateur détermine où a[i][j] se trouve dans la représentation au niveau ligne ? Une formule simple calcule l'indice à partir de i et j. La formule dans ce cas est 2i + j. Il n'est pas compliqué de voir comment on obtient cette formule. Chaque ligne fait deux éléments de long, donc le premier élément de la ligne i est à l'emplacement 2i. Puis on obtient l'emplacement de la colonne j en ajoutant j à 2i. Cette analyse montre également comment la formule est généralisée à un tableau de N colonnes : N × i + j.

Notez que la formule ne dépend pas du nombre de lignes.

Pour illustrer, voyons comment gcc compile le code suivant (utilisant le tableau a défini plus haut) :

Sélectionnez
x = a[ i ][ j ];

La Figure 5.6 montre le code assembleur correspondant.

Image non disponible

Donc, le compilateur convertit grossièrement le code en :

Sélectionnez
x = (&a[0][0] + 2i + j );

et en fait, le programmeur pourrait l'écrire de cette manière et obtenir le même résultat.

Il n'y a rien de magique à propos du choix de la représentation niveau ligne du tableau. Une représentation niveau colonne fonctionnerait également :

Image non disponible

Dans la représentation niveau colonne, chaque colonne est stockée de manière contigüe. L'élément [i][j] est stocké à l'emplacement i+3j. D'autres langages (FORTRAN, par exemple) utilisent la représentation niveau colonne.

C'est important lorsque l'on interface du code provenant de multiples langages.

VI-A-5-b. Dimensions supérieures à deux

Pour les dimensions supérieures à deux, la même idée de base est appliquée. Considérons un tableau à trois dimensions :

Sélectionnez
int b [4][3][2];

Ce tableau serait stocké comme s'il était composé de trois tableaux à deux dimensions, chacun de taille [3][2] stockés consécutivement en mémoire. Le tableau ci-dessous montre comment il commence :

Image non disponible

La formule pour calculer la position de b[i][j][k] est 6i + 2j + k. Le 6 est déterminé par la taille des tableaux [3][2]. En général, pour un tableau de dimension a[L][M][N] l'emplacement de l'élément a[i][j][k] sera M ×N ×i+N ×j +k. Notez, là encore, que la dimension L n'apparaît pas dans la formule.

Pour les dimensions plus grandes, le même procédé est généralisé. Pour un tableau à n dimensions de dimension D1 à Dn, l'emplacement d'un élément repéré par les indices i1 à in est donné par la formule :

D2 × D3 · · · × Dn × i1 + D3 × D4 · · · × Dn × i2 + · · · + Dn × in−1 + in

ou pour les fanas de maths, on peut l'écrire de façon plus concise :

Image non disponible

La première dimension, D1, n'apparaît pas dans la formule.

Pour la représentation au niveau colonne, la formule générale serait :

i1 +D1 ×i2 +· · ·+D1 ×D2 ×· · ·×Dn−2 ×in−1 +D1 ×D2 ×· · ·×Dn−1 ×in

ou dans la notation des fanas de maths :

Image non disponible

Dans ce cas, c'est la dernière dimension, Dn, qui n'apparaît pas dans la formule.

C'est là que vous comprenez que l'auteur est un major de physique (ou la référence à FORTRAN vous avait déjà mis sur la voie ?)

VI-A-5-c. Passer des tableaux multidimensionnels comme paramètres en C

La représentation au niveau ligne des tableaux multidimensionnels a un effet direct sur la programmation C. Pour les tableaux à une dimension, la taille du tableau n'est pas nécessaire pour calculer l'emplacement en mémoire de n'importe quel élément. Ce n'est pas vrai pour les tableaux multidimensionnels.

Pour accéder aux éléments de ces tableaux, le compilateur doit connaître toutes les dimensions sauf la première. Cela saute aux yeux lorsque l'on observe le prototype d'une fonction qui prend un tableau multidimensionnel comme paramètre. Ce qui suit ne compilera pas :

Sélectionnez
void f ( int a [ ][ ] ); / pas d' information sur la dimension /

Cependant, ce qui suit compile :

Sélectionnez
void f ( int a [ ][2] );

Tout tableau à deux dimensions à deux colonnes peut être passé à cette fonction. La première dimension n'est pas nécessaire(25).

Ne confondez pas avec une fonction ayant ce prototype :

Sélectionnez
void f ( int a [ ] );

Cela définit un tableau à une dimension de pointeurs sur des entiers (qui peut à son tour être utilisé pour créer un tableau de tableaux qui se comportent plus comme un tableau à deux dimensions).

Pour les tableaux de dimensions supérieures, toutes les dimensions sauf la première doivent être données pour les paramètres. Par exemple, un paramètre tableau à quatre dimensions peut être passé de la façon suivante :

Sélectionnez
void f ( int a [ ][4][3][2] );

VI-B. Instructions de tableaux/chaînes

La famille des processeurs 80x86 fournit plusieurs instructions conçues pour travailler avec les tableaux. Ces instructions sont appelées instructions de chaînes. Elles utilisent les registres d'index (ESI et EDI) pour effectuer une opération puis incrémentent ou décrémentent automatiquement l'un des registres d'index ou les deux. Le drapeau de direction (DF) dans le registre FLAGS détermine si les registres d'index sont incrémentés ou décrémentés.

Il y a deux instructions qui modifient le drapeau de direction :

CLD éteint le drapeau de direction. Les registres d'index sont alors incrémentés ;

STD allume le drapeau de direction. Les registres d'index sont alors décrémentés.

Une erreur très courante dans la programmation 80x86 est d'oublier de positionner le drapeau de direction. Cela conduit souvent à un code qui fonctionne la plupart du temps (lorsque le drapeau de direction est dans la position désirée), mais ne fonctionne pas tout le temps.

VI-B-1. Lire et écrire en mémoire

Les instructions de chaînes les plus simples lisent ou écrivent en mémoire, ou les deux. Elles peuvent lire ou écrire un octet, un mot ou un double-mot à la fois. La Figure 5.7 montre ces instructions avec une brève description de ce qu'elles font en pseudo-code.

Image non disponible

Il y a plusieurs choses à noter ici. Tout d'abord, ESI est utilisé pour lire et EDI pour écrire. C'est facile à retenir si l'on se souvient que SI signifie Source Index (Indice Source) et DI Destination Index (Indice de Destination). Ensuite, notez que le registre qui contient la donnée est fixe (AL, AX ou EAX). Enfin, notez que les instructions de stockage utilisent ES pour déterminer le segment dans lequel écrire, pas DS. En programmation en mode protégé, ce n'est habituellement pas un problème, puisqu'il n'y a qu'un segment de données et ES est automatiquement initialisé pour y faire référence (tout comme DS). Cependant, en programmation en mode réel, il est très important pour le programmeur d'initialiser ES avec la valeur de sélecteur de segment correcte(26). La Figure 5.8 montre un exemple de l'utilisation de ces instructions qui copie un tableau dans un autre.

Image non disponible

La combinaison des instructions LODSx et STOSx (comme aux lignes 13 et 14 de la Figure 5.8) est très courante. En fait, cette combinaison peut être effectuée par une seule instruction de chaînes MOVSx. La Figure 5.9 décrit les opérations effectuées par cette instruction.

Image non disponible

Les lignes 13 et 14 de la Figure 5.8 pourraient être remplacées par une seule instruction MOVSD avec le même résultat. La seule différence serait que le registre EAX ne serait pas utilisé du tout dans la boucle.

VI-B-2. Le préfixe d'instruction REP

La famille 80x86 fournit un préfixe d'instruction spécial(27) appelé REP qui peut être utilisé avec les instructions de chaînes présentées ci-dessus. Ce préfixe indique au processeur de répéter l'instruction de chaînes qui suit un nombre précis de fois. Le registre ECX est utilisé pour compter les itérations (exactement comme pour l'instruction LOOP). En utilisant le préfixe REP, la boucle de la Figure 5.8 (lignes 12 à 15) pourrait être remplacée par une seule ligne :

Sélectionnez
rep movsd

La Figure 5.10 montre un autre exemple qui met à zéro le contenu d'un tableau.

Image non disponible

VI-B-3. Instructions de comparaison de chaînes

La Figure 5.11 montre plusieurs nouvelles instructions de chaînes qui peuvent être utilisées pour comparer des données en mémoire entre elles ou avec des registres. Elles sont utiles pour comparer des tableaux ou effectuer des recherches. Elles positionnent le registre FLAGS exactement comme l'instruction CMP. Les instructions CMPSx comparent les emplacements mémoire correspondants et les instructions SCASx scannent des emplacements mémoire pour une valeur particulière.

La Figure 5.12 montre un court extrait de code qui recherche le nombre 12 dans un tableau de doubles-mots.

Image non disponible

L'instruction SCASD à la ligne 10 ajoute toujours 4 à EDI, même si la valeur recherchée est trouvée. Donc, si l'on veut trouver l'adresse du 12 dans le tableau, il est nécessaire de soustraire 4 de EDI (comme le fait la ligne 16).

VI-B-4. Les préfixes d'instruction REPx

Il y a plusieurs autres préfixes d'instruction du même genre que REP qui peuvent être utilisés avec les instructions de comparaison de chaînes.

La Figure 5.13 montre les deux nouveaux préfixes et décrit ce qu'ils font.

Image non disponible

REPE et REPZ sont simplement des synonymes pour le même préfixe (comme le sont REPNE et REPNZ). Si l'instruction de comparaison de chaînes répétée stoppe à cause du résultat de la comparaison, le ou les registres d'index sont quand même incrémentés et ECX décrémenté, cependant, le registre FLAGS contient toujours l'état qu'il avait à la fin de la répétition. Donc, il est possible d'utiliser le drapeau Z pour déterminer si la comparaison s'est arrêtée à cause de son résultat ou si c'est parce que ECX a atteint zéro.

Pourquoi ne peut-on pas simplement regarder si ECX est à zéro après la comparaison répétée ?

La Figure 5.14 montre un extrait de code qui détermine si deux blocs de mémoire sont égaux.

Image non disponible

Le JE de la ligne 7 de l'exemple vérifie le résultat de l'instruction précédente. Si la comparaison s'est arrêtée parce qu'elle a trouvé deux octets différents, le drapeau Z sera toujours éteint et aucun branchement n'est effectué, cependant, si la comparaison s'est arrêtée parce que ECX a atteint zéro, le drapeau Z sera toujours allumé et le code se branche à l'étiquette equal.

VI-B-5. Exemple

Cette section contient un fichier source assembleur avec plusieurs fonctions qui implémentent des opérations sur les tableaux en utilisant des instructions de chaînes. Beaucoup de ces fonctions font doublons avec des fonctions familières de la bibliothèque C.

memory.asm
Sélectionnez
1 global _asm_copy, _asm_find, _asm_strlen, _asm_strcpy
2
3 segment .text
4 ; fonction _asm_copy
5 ; copie deux blocs de mémoire
6 ; prototype C
7 ; void asm_copy( void * dest, const void * src, unsigned sz);
8 ; paramètres :
9 ; dest - pointeur sur le tampon vers lequel copier
10 ; src - pointeur sur le tampon depuis lequel copier
11 ; sz - nombre d'octets à copier
12
13 ; ci-dessous, quelques symboles utiles sont définis
14
15 ?fine dest [ebp+8]
16 ?fine src [ebp+12]
17 ?fine sz [ebp+16]
18 _asm_copy :
19 enter 0, 0
20 push esi
21 push edi
22
23 mov esi, src ; esi = adresse du tampon depuis lequel copier
24 mov edi, dest ; edi = adresse du tampon vers lequel copier
25 mov ecx, sz ; ecx = nombre d'octets à copier
26
27 cld ; éteint le drapeau de direction
28 rep movsb ; exécute movsb ECX fois
29
30 pop edi
31 pop esi
32 leave
33 ret
34
35
36 ; fonction _asm_find
37 ; recherche un octet donné en mémoire
38 ; void * asm_find( const void * src, char target, unsigned sz);
39 ; paramètres :
40 ; src - pointeur sur le tampon dans lequel chercher
41 ; target - octet à rechercher
42 ; sz - nombre d'octets dans le tampon
43 ; valeur de retour :
44 ; si l'élément recherché est trouvé, un pointeur vers sa première occurrence dans le tampon
45 ; est retourné
46 ; sinon, NULL est retourné
47 ; NOTE : l'élément à rechercher est un octet, mais il est empilé comme une valeur dword.
48 ; La valeur de l'octet est stockée dans les 8 bits de poids faible.
49 ;
50 ?fine src [ebp+8]
51 ?fine target [ebp+12]
52 ?fine sz [ebp+16]
53
54 _asm_find :
55 enter 0,0
56 push edi
57
58 mov eax, target ; al a la valeur recherchée
59 mov edi, src
60 mov ecx, sz
61 cld
62
63 repne scasb ; scanne jusqu'à ce que ECX == 0 ou [ES:EDI] == AL
64
65 je found_it ; si le drapeau zéro est allumé, on a trouvé
66 mov eax, 0 ; si pas trouvé, retourner un pointeur NULL
67 jmp short quit
68 found_it :
69 mov eax, edi
70 dec eax ; si trouvé retourner (DI - 1)
71 quit :
72 pop edi
73 leave
74 ret
75
76
77 ; fonction _asm_strlen
78 ; retourne la taille d'une chaîne
79 ; unsigned asm_strlen( const char * );
80 ; paramètre :
81 ; src - pointeur sur la chaîne
82 ; valeur de retour :
83 ; nombre de caractères dans la chaîne (sans compter le 0 terminal) (dans EAX)
84
85 ?fine src [ebp + 8]
86 _asm_strlen :
87 enter 0,0
88 push edi
89
90 mov edi, src ; edi = pointeur sur la chaîne
91 mov ecx, 0FFFFFFFFh ; utilise la plus grande valeur possible de ECX
92 xor al,al ; al = 0
93 cld
94
95 repnz scasb ; recherche le 0 terminal
96
97 ;
98 ; repnz ira un cran trop loin, donc la longueur vaut FFFFFFFE - ECX,
99 ; pas FFFFFFFF - ECX
100 ;
101 mov eax,0FFFFFFFEh
102 sub eax, ecx ; longueur = 0FFFFFFFEh - ecx
103
104 pop edi
105 leave
106 ret
107
108 ; fonction _asm_strcpy
109 ; copie une chaîne
110 ; void asm_strcpy( char * dest, const char * src);
111 ; paramètres :
112 ; dest - pointeur sur la chaine vers laquelle copier
113 ; src - pointeur sur la chaîne depuis laquelle copier
114 ;
115 ?fine dest [ebp + 8]
116 ?fine src [ebp + 12]
117 _asm_strcpy :
118 enter 0,0
119 push esi
120 push edi
121
122 mov edi, dest
123 mov esi, src
124 cld
125 cpy_loop:
126 lodsb ; charge AL & incrémente SI
127 stosb ; stocke AL & incrémente DI
128 or al, al ; positionne les drapeaux de condition
129 jnz cpy_loop ; si l'on n’est pas après le 0 terminal, on continue
130
131 pop edi
132 pop esi
133 leave
134 ret
memex.c
Sélectionnez
1 #include <stdio.h>
2
3 #dene STR_SIZE 30
4 / prototypes /
5
6 void asm_copy( void , const void , unsigned ) __attribute__((cdecl));
7 void asm_nd( const void ,
8 char target , unsigned ) __attribute__((cdecl));
9 unsigned asm_strlen( const char ) __attribute__((cdecl));
10 void asm_strcpy( char , const char ) __attribute__((cdecl));
11
12 int main()
13 {
14 char st1 [STR_SIZE] = "chaine test";
15 char st2 [STR_SIZE];
16 char st ;
17 char ch;
18
19 asm_copy(st2, st1, STR_SIZE); / copie les 30 caractères de la chaine /
20 printf ("%s\n", st2);
21
22 printf ("Entrez un è caractre : " ); / recherche un octet dans la îchane /
23 scanf("%c%[^\n]", &ch);
24 st = asm_nd(st2, ch, STR_SIZE);
25 if ( st )
26 printf ("éTrouv : %s\n", st );
27 else
28 printf ("Pas étrouv\n");
29
30 st1 [0] = 0;
31 printf ("Entrez une îchane :");
32 scanf("%s", st1);
33 printf ("longueur = %u\n", asm_strlen(st1));
34
35 asm_strcpy( st2, st1 ); / copie des édonnes dans la îchane /
36 printf ("%s\n", st2 );
37
38 return 0;
39 }

VII. Virgule flottante

VII-A. Représentation en virgule flottante

VII-A-1. Nombres binaires non entiers

Lorsque nous avons parlé des systèmes numériques dans le premier chapitre, nous n'avons abordé que les nombres entiers. Évidemment, il est possible de représenter des nombres non entiers dans des bases autres que le décimal.

En décimal, les chiffres à droite du symbole décimal sont associés à des puissances

négatives de 10 :

0, 123 = 1 × 10-1 + 2 × 10-2 + 3 × 10-3

Ce n'est pas étonnant, les nombres binaires fonctionnent de la même façon :

0, 1012 = 1 × 2-1 + 0 × 2-2 + 1 × 2-3 = 0, 625

Cette idée peut être associée avec les méthodes appliquées aux entiers dans le Chapitre II pour convertir un nombre quelconque :

110, 0112 = 4 + 2 + 0, 25 + 0, 125 = 6, 375

Convertir du binaire vers le décimal n'est pas très difficile non plus. Divisez le nombre en deux parties : entière et décimale. Convertissez la partie entière en binaire en utilisant les méthodes du Chapitre II, la partie décimale est convertie en utilisant la méthode décrite ci-dessous.

Considérons une partie décimale binaire dont les bits sont notés a, b, c… Le nombre en binaire ressemble alors à :

0, abcdef…

Multipliez le nombre par deux. La représentation du nouveau nombre sera :

a, bcdef…

Notez que le premier bit est maintenant en tête. Remplacez le a par 0 pour obtenir :

0, bcdef…

et multipliez à nouveau par deux, vous obtenez :

b, cdef…

Maintenant le deuxième bit (b) est à la première place. Cette procédure peut être répétée jusqu'à ce qu'autant de bits que nécessaire soient trouvés.

La Figure 6.1 montre un exemple réel qui converti 0,5625 en binaire.

Image non disponible

La méthode s'arrête lorsque la partie décimale arrive à zéro.

Prenons un autre exemple, considérons la conversion de 23,85 en binaire, il est facile de convertir la partie entière (23 = 101112), mais qu'en est-il de la partie décimale (0, 85) ?

La Figure 6.2 montre le début de ce calcul.

Image non disponible
Image non disponible

Une conséquence importante du calcul ci-dessus est que 23,85 ne peut pas être représenté exactement en binaire en utilisant un nombre fini de bits (tout comme 1/3 ne peut pas être représenté en décimal avec un nombre fini de chiffres). Comme le montre ce chapitre, les variables float et double en C sont stockées en binaire. Donc, les valeurs comme 23,85 ne peuvent pas être stockées exactement dans ce genre de variables. Seule une approximation de 23,85 peut être stockée.

Pour simplifier le matériel, les nombres en virgule flottante sont stockés dans un format standard. Ce format utilise la notation scientifique (mais en binaire, en utilisant des puissances de 2, pas de 10). Par exemple, 23,85 ou 10111, 11011001100110…2 serait stocké sous la forme :

1, 011111011001100110… × 2100

(où l'exposant (100) est en binaire). Un nombre en virgule flottante normalisé a la forme :

1, ssssssssssssssss × 2eeeeeee

où 1, sssssssssssss est la mantisse et eeeeeeee est l'exposant.

VII-A-2. Représentation en virgule flottante IEEE

L'IEEE (Institute of Electrical and Electronic Engineers) est une organisation internationale qui a conçu des formats binaires spécifiques pour stocker les nombres en virgule flottante. Ce format est utilisé sur la plupart des ordinateurs (mais pas tous !) fabriqués de nos jours. Souvent, il est supporté par le matériel de l'ordinateur lui-même. Par exemple, les coprocesseurs numériques (ou arithmétiques) d'Intel (qui sont intégrés à tous leurs processeurs depuis le Pentium) l'utilisent. L'IEEE définit deux formats différents avec des précisions différentes : simple et double précision. La simple précision est utilisée pour les variables float en C et la double précision pour les variables double.

Le coprocesseur arithmétique d'Intel utilise également une troisième précision plus élevée appelée précision étendue. En fait toutes les données dans le coprocesseur sont dans ce format. Lorsqu'elles sont stockées en mémoire depuis le coprocesseur, elles sont converties soit en simple, soit en double précision automatiquement(28). La précision étendue utilise un format général légèrement différent des formats float et double de l'IEEE, nous n'en parlerons donc pas ici.

VII-A-2-a. Simple précision IEEE

La virgule flottante simple précision utilise 32 bits pour encoder le nombre.

Elle est généralement précise jusqu'à 7 chiffres significatifs. Les nombres en virgule flottante sont généralement stockés dans un format beaucoup plus compliqué que celui des entiers.

La Figure 6.3 montre le format de base d'un nombre en simple précision IEEE.

Image non disponible

Ce format a plusieurs inconvénients. Les nombres en virgule flottante n'utilisent pas la représentation en complément à 2 pour les nombres négatifs. Ils utilisent une représentation en grandeur signée. Le bit 31 détermine le signe du nombre.

L'exposant binaire n'est pas stocké directement. À la place, la somme de l'exposant et de 7F est stockée dans les bits 23 à 30, cet exposant décalé est toujours positif ou nul.

La partie décimale suppose une mantisse normalisée (de la forme 1, sssssssss).

Comme le premier bit est toujours un 1, le 1 de gauche n'est pas stocké ! Cela permet le stockage d'un bit additionnel à la fin et augmente donc légèrement la précision. Cette idée est appelée représentation en 1 masqué.

Il faut toujours garder à l'esprit que les octets 41 BE CC CD peuvent être interprétés de façons différentes selon ce qu'en fait le programme ! Vus comme un nombre en virgule flottante en simple précision, ils représentent 23,850000381, mais vus comme un entier double-mot, ils représentent 1,103,023,309 ! Le processeur ne sait pas quelle est la bonne interprétation !

Comment serait stocké 23,85 ? Tout d'abord, il est positif, donc le bit de signe est à 0. Ensuite, l'exposant réel est 4, donc l'exposant décalé est

7F + 4 = 8316.

Enfin, la fraction vaut 01111101100110011001100 (souvenez-vous que le 1 de tête est masqué). En les mettant bout à bout (pour clarifier les différentes sections du format en virgule flottante, le bit de signe et la fraction ont été soulignés et les bits ont été regroupés en groupes de 4 bits) :

Image non disponible

Cela ne fait pas exactement 23,85 (puisqu'il s'agit d'un binaire répétitif). Si l'on convertit le chiffre ci-dessus en décimal, on constate qu'il vaut approximativement 23,849998474, ce nombre est très proche de 23,85, mais n'est pas exactement le même. En réalité, en C, 23,85 ne serait pas représenté exactement comme ci-dessus. Comme le bit le plus à gauche qui a été supprimé de la représentation exacte valait 1, le dernier bit est arrondi à 1, donc 23,85 serait représenté par 41 BE CC CD en hexa en utilisant la simple précision. En décimal, ce chiffre vaut 23,850000381 ce qui est une meilleure approximation de 23,85.

Comment serait représenté -23,85 ? Il suffit de changer le bit de signe :

C1 BE CC CD. Ne prenez pas le complément à 2 !

Certaines combinaisons de e et f ont une signification spéciale pour les flottants IEEE. Le Tableau 6.1 décrit ces valeurs.

Image non disponible

Un infini est produit par un dépassement de capacité ou une division par zéro. Un résultat indéfini

est produit par une opération invalide comme rechercher la racine carrée d'un nombre négatif, additionner deux infinis, etc.

Les nombres en simple précision normalisés peuvent prendre des valeurs de

Image non disponible

VII-A-2-b. Nombres dénormalisés

Les nombres dénormalisés peuvent être utilisés pour représenter des nombres avec des grandeurs trop petites à normaliser (i.e. inférieures à 1, 0 × 2-126).

Par exemple, considérons le nombre Image non disponible

Dans la forme normalisée, l'exposant est trop petit. Par contre, il peut être représenté sous une forme non normalisée : 0, 010012 × 2-127. Pour stocker ce nombre, l'exposant décalé est positionné à 0 (voir Tableau 6.1) et la partie décimale est la mantisse complète du nombre écrite comme un produit avec 2-127 (i.e. tous les bits sont stockés, y compris le 1 à gauche du point décimal). La représentation de 1, 001 × 2-129 est donc :

Image non disponible

VII-A-2-c. Double précision IEEE

La double précision IEEE utilise 64 bits pour représenter les nombres et est précise jusqu'à environ 15 chiffres significatifs. Comme le montre la Figure 6.4, le format de base est très similaire à celui de la simple précision.

Image non disponible

Plus de bits sont utilisés pour l'exposant décalé (11) et la partie décimale (52).

La plage de valeurs plus grande pour l'exposant décalé a deux conséquences. La première est qu'il est calculé en faisant la somme de l'exposant réel et de 3FF (1023) (pas 7F comme pour la simple précision). Deuxièmement, une grande plage d'exposants réels (et donc une plus grande plage de grandeurs) est disponible. Les grandeurs en double-précision peuvent prendre des valeurs entre environ 13-308 et 10308 .

C'est la plus grande taille du champ réservé à la partie décimale qui est reponsable de l'augmentation du nombre de chiffres significatifs pour les valeurs doubles.

Pour illustrer cela, reprenons 23,85. L'exposant décalé sera 4+3FF = 403 en hexa. Donc, la représentation double sera :

Image non disponible

ou 40 37 D9 99 99 99 99 9A en hexa. Si l'on reconvertit ce nombre en décimal, on trouve 23,8500000000000014 (il y a 12 zéros !) ce qui est une approximation de 23,85 nettement meilleure.

La double précision a les mêmes valeurs spéciales que la simple précision(29).

Les nombres dénormalisés sont également très similaires. La seule différence principale est que les nombres doubles dénormalisés utilisent 2-1023 au lieu de 2-127.

VII-B. Arithmétique en virgule flottante

L'arithmétique en virgule flottante sur un ordinateur est différente de celle des mathématiques. En mathématiques, tous les nombres peuvent être considérés comme exacts. Comme nous l'avons montré dans la section précédente, sur un ordinateur beaucoup de nombres ne peuvent pas être représentés exactement avec un nombre fini de bits. Tous les calculs sont effectués avec une précision limitée. Dans les exemples de cette section, des nombres avec une mantisse de 8 bits seront utilisés pour plus de simplicité.

VII-B-1. Addition

Pour additionner deux nombres en virgule flottante, les exposants doivent être égaux. S'ils ne le sont pas déjà, alors, il faut les rendre égaux en décalant la mantisse du nombre ayant le plus petit exposant. Par exemple, considérons

10, 375 + 6, 34375 = 16, 71875 ou en binaire :

Image non disponible

Ces deux nombres n'ont pas le même exposant, donc on décale la mantisse pour rendre les exposants égaux, puis on effectue l'addition :

Image non disponible

Notez que le décalage de 1, 1001011 × 22 supprime le 1 de tête et arrondi plus tard le résultat en 0, 1100110 × 23. Le résultat de l'addition, 10, 0001100 × 23 ou 1, 00001100 × 24), est égal à

10000,1102 ou 16,75, ce n'est pas égal à la réponse exacte (16,71875) ! Il ne s'agit que d'une approximation due aux erreurs d'arrondi du processus d'addition.

Il est important de réaliser que l'arithmétique en virgule flottante sur un ordinateur (ou une calculatrice) est toujours une approximation. Les lois des mathématiques ne fonctionnent pas toujours avec les nombres en virgule flottante sur un ordinateur. Les mathématiques supposent une précision infinie qu'aucun ordinateur ne peut atteindre. Par exemple, les mathématiques nous apprennent que (a + b) − b = a, cependant, ce n'est pas forcément exactement vrai sur un ordinateur !

VII-B-2. Soustraction

La soustraction fonctionne d'une façon similaire à l'addition et souffre des mêmes problèmes. Par exemple, considérons 16, 75 − 15, 9375 = 0, 8125 :

Image non disponible

Décaler 1, 1111111 × 23 donne (en arrondissant) 1, 0000000 × 24

Image non disponible

VII-B-3. Multiplication et division

Pour la multiplication, les mantisses sont multiplées et les exposants sont additionnés. Considérons 10, 375 × 2, 5 = 25, 9375 :

Image non disponible

Bien sûr, le résultat réel serait arrondi à 8 bits pour donner :

1, 1010000 × 24 = 11010, 0002 = 26

La division est plus compliquée, mais souffre des mêmes problèmes avec des erreurs d'arrondi.

VII-B-4. Conséquences sur la programmation

Le point essentiel de cette section est que les calculs en virgule flottante ne sont pas exacts. Le programmeur doit en être conscient. Une erreur courante que les programmeurs font avec les nombres en virgule flottante est de les comparer en supposant qu'un calcul est exact. Par exemple, considérons une fonction appelée f(x) qui effectue un calcul complexe et un programme qui essaie de trouver la racine de la fonction(30). On peut être tenté d'utiliser l'expression suivante pour vérifier si x est une racine :

Sélectionnez
if ( f(x) == 0.0 )

Mais, que se passe-t-il si f(x) retourne 1×10-30 ? Cela signifie très probablement que x est une très bonne approximation d'une racine réelle, cependant, l'égalité ne sera pas vérifiée. Il n'y a peut-être aucune valeur en virgule flottante IEEE de x qui renvoie exactement zéro, en raison des erreurs d'arrondi dans f(x).

Une meilleure méthode serait d'utiliser :

Sélectionnez
if ( fabs(f(x)) < EPS )

où EPS est une macro définissant une très petite valeur positive (du genre 1 x 10-10). Cette expression est vraie dès que f(x) est très proche de zéro.

En général, pour comparer une valeur en virgule flottante (disons x) à une autre (y), on utilise :

Sélectionnez
if ( fabs(x &#8722; y)/fabs(y) < EPS )

VII-C. Le coprocesseur arithmétique

VII-C-1. Matériel

Les premiers processeurs Intel n'avaient pas de support matériel pour les opérations en virgule flottante. Cela ne signifie pas qu'ils ne pouvaient pas effectuer de telles opérations. Cela signifie seulement qu'elles devaient être réalisées par des procédures composées de beaucoup d'instructions qui n'étaient pas en virgule flottante. Pour ces systèmes, Intel fournissait une puce appelée coprocesseur mathématique. Un coprocesseur mathématique a des instructions machine qui effectuent beaucoup d'opérations en virgule flottante beaucoup plus rapidement qu'en utilisant une procédure logicielle (sur les premiers processeurs, au moins 10 fois plus vite !). Le coprocesseur pour le 8086/8088 s'appelait le 8087, pour le 80286, il y avait un 80287 et pour le 80386, un 80387, le processeur 80486DX intégrait le coprocesseur mathématique dans le 80486 lui-même(31). Depuis le Pentium, toutes les générations de processeurs 80x86 ont un coprocesseur mathématique intégré. Cependant, on le programme toujours comme s'il s'agissait d'une unité séparée. Même les systèmes plus anciens sans coprocesseur peuvent installer un logiciel qui émule un coprocesseur mathématique. Cet émulateur est automatiquement activé lorsqu'un programme exécute une instruction du coprocesseur et lance la procédure logicielle qui produit le même résultat que celui qu'aurait donné le coprocesseur (bien que cela soit plus lent, bien sûr).

Le coprocesseur arithmétique a huit registres de virgule flottante. Chaque registre contient 80 bits de données. Les nombres en virgule flottante sont toujours stockés sous la forme de nombres 80 bits en précision étendue dans ces registres. Les registres sont appelés ST0, ST1, ST2… ST7. Les registres en virgule flottante sont utilisés différemment des registres entiers du processeur principal. Ils sont organisés comme une pile. Souvenez-vous qu'une pile est une liste Last-In First-Out (LIFO, dernier entré, premier sorti). ST0 fait toujours référence à la valeur au sommet de la pile. Tous les nouveaux nombres sont ajoutés au sommet de la pile. Les nombres existants sont décalés vers le bas pour faire de la place au nouveau.

Il y a également un registre de statut dans le coprocesseur arithmétique.

Il a plusieurs drapeaux. Seuls les 4 drapeaux utilisés pour les comparaisons seront traités : C0, C1, C2 et C3. Leur utilisation est traitée plus tard.

VII-C-2. Instructions

Pour faciliter la distinction entre les instructions du processeur normal et celles du coprocesseur, tous les mnémoniques du coprocesseur commencent par un F.

VII-C-2-a. Chargement et stockage

Il y a plusieurs instructions qui chargent des données au sommet de la pile du coprocesseur :

FLD source charge un nombre en virgule flottante depuis la mémoire vers

le sommet de la pile. La source peut être un nombre en simple,

double ou précision étendue ou un registre du coprocesseur.

FILD source lit un entier depuis la mémoire, le convertit en flottant et stocke le résultat au sommet de la pile. La source peut être un mot, un double-mot ou un quadruple-mot.

FLD1 Stocke un 1 au sommet de la pile.

FLDZ Stocke un zéro au sommet de la pile.

Il y a également plusieurs instructions qui déplacent les données depuis la pile vers la mémoire. Certaines de ces instructions retirent le nombre de la pile en même temps qu'elles le déplacent.

FST dest stocke le sommet de la pile (ST0) en mémoire. La destination peut être un nombre en simple ou double précision ou un registre du coprocesseur.

FSTP dest stocke le sommet de la pile en mémoire, exactement comme FST ; cependant, une fois le nombre stocké, sa valeur est retirée de la pile. La destination peut être un nombre en simple, double ou précision étendue ou un registre du coprocesseur.

FIST dest stocke la valeur au sommet de la pile convertie en entier en mémoire. La destination peut être un mot ou un double-mot. La pile en elle-même reste inchangée. La façon dont est converti le nombre en entier dépend de certains bits dans le mot de contrôle du coprocesseur. Il s'agit d'un registre d'un mot spécial (pas en virgule flottante) qui contrôle le fonctionnement du coprocesseur. Par défaut, le mot de contrôle est initialisé afin qu'il arrondisse à l'entier le plus proche lorsqu'il convertit vers un entier. Cependant, les instructions FSTCW (Store Control Word, stocker le mot de contrôle) et FLDCW (Load Control Word, charger le mot de contrôle) peuvent être utilisées pour changer ce comportement.

FISTP dest identique à FIST sauf en deux points. Le sommet de la pile est supprimé et la destination peut également être un quadruple-mot.

Il y a deux autres instructions qui peuvent placer ou retirer des données de la pile.

FXCH STn échange les valeurs de ST0 et STn sur la pile (où n est un numéro de registre entre 1 et 7).

FFREE STn libère un registre sur la pile en le marquant comme inutilisé ou vide.

VII-C-2-b. Addition et soustraction

Chacune des instructions d'addition calcule la somme de ST0 et d'un autre opérande. Le résultat est toujours stocké dans un registre du coprocesseur.

FADD source ST0 += source . La source peut être n'importe quel registre du coprocesseur ou un nombre en simple ou double précision en mémoire.

FADD dest, ST0 dest += ST0. La destination peut être n'importe quel registre du coprocesseur.

FADDP dest ou FADDP dest, STO dest += ST0 puis ST0 est retiré de la pile. La destination peut être n'importe quel registre du coprocesseur.

FIADD source ST0 += (float) source . Ajoute un entier à ST0. La source doit être un mot ou un double-mot en mémoire.

Il y a deux fois plus d'instructions pour la soustraction que pour l'addition, car l'ordre des opérandes est important pour la soustraction (i.e. a+b = b+a, mais a−b ≠ b−a !). Pour chaque instruction, il y a un miroir qui effectue la soustraction dans l'ordre inverse. Ces instructions inverses se finissent toutes soit par R soit par RP. La Figure 6.5 montre un court extrait de code qui ajoute les éléments d'un tableau de doubles.

Image non disponible

Au niveau des lignes 10 et 13, il faut spécifier la taille de l'opérande mémoire. Sinon l'assembleur ne saurait pas si l'opérande mémoire est un float (dword) ou un double (qword).

FSUB source ST0 -= source. La source peut être n'importe quel registre du coprocesseur ou un nombre en simple ou double précision en mémoire.

FSUBR source ST0 = source - ST0. La source peut être n'importe quel registre du coprocesseur ou un nombre en simple ou double précision en mémoire.

FSUB dest, ST0 dest -= ST0. La destination peut être n'importe quel registre du coprocesseur.

FSUBR dest, ST0 dest = ST0 - dest . La destination peut être n'importe quel registre du coprocesseur.

FSUBP dest ou FSUBP dest, STO dest -= ST0 puis retire ST0 de la pile. La destination peut être n'importe quel registre du coprocesseur.

FSUBRP dest ou FSUBRP dest, ST0 dest = ST0 - dest puis retire ST0 de la pile. La destination peut être n'importe quel registre du coprocesseur.

FISUB source ST0 -= (float) source . Soustrait un entier de ST0. La source doit être un mot ou un double mot en mémoire.

FISUBR source ST0 = (float) source - ST0. Soustrait ST0 d'un entier. La source doit être un mot ou un double-mot en mémoire.

VII-C-2-c. Multiplication et division

Les instructions de multiplication sont totalement analogues à celles d'addition.

FMUL source ST0 *= source . La source peut être n'importe quel registre du coprocesseur ou un nombre en simple ou double précision en mémoire.

FMUL dest, ST0 dest *= ST0. La destination peut être n'importe quel registre du coprocesseur.

FMULP dest ou FMULP dest, ST0 dest *= ST0 puis retire ST0 de la pile. La destination peut être n'importe quel registre du coprocesseur.

FIMUL source ST0 *= (float) source. Multiplie un entier avec ST0. La source peut être un mot ou un double-mot en mémoire.

Ce n'est pas étonnant, les instructions de division sont analogues à celles de soustraction. La division par zéro donne l'infini.

FDIV source ST0 /= source . La source peut être n'importe quel registre du coprocesseur ou un nombre en simple ou double précision en mémoire.

FDIVR source ST0 = source / ST0. La source peut être n'importe quel registre du coprocesseur ou un nombre en simple ou double précision en mémoire.

FDIV dest, ST0 dest /= ST0. La destination peut être n'importe quel registre du coprocesseur.

FDIVR dest, ST0 dest = ST0 / dest . La destination peut être n'importe quel registre du coprocesseur.

FDIVP dest ou FDIVP dest, ST0 dest /= ST0 puis retire ST0 de la pile. La destination peut être n'importe quel registre du coprocesseur.

FDIVRP dest ou FDIVRP dest, ST0 dest = ST0 / dest puis retire ST0 de la pile. La destination peut être n'importe quel registre du coprocesseur.

FIDIV source ST0 /= (float) source. Divise ST0 par un entier. La src doit être un mot ou un double-mot en mémoire.

FIDIVR source ST0 = (float) source / ST0. Divise un entier par ST0. La source doit être un mot ou un double-mot en mémoire.

VII-C-2-d. Comparaisons

Le coprocesseur effectue également des comparaisons de nombres en virgule flottante. La famille d'instructions FCOM est faite pour ça.

FCOM source compare ST0 et source. La source peut être un registre du coprocesseur ou un float ou un double en mémoire.

FCOMP source compare ST0 et source, puis retire ST0 de la pile. La source peut être un registre du coprocesseur ou un float ou un double en mémoire.

FCOMPP compare ST0 et ST1, puis retire ST0 et ST1 de la pile.

FICOM source compare ST0 et (float) source. La source peut être un entier sur un mot ou un double-mot en mémoire.

FICOMP source compare ST0 et (float)source, puis retire ST0 de la pile. La source peut être un entier sur un mot ou un double-mot en mémoire.

FTST compare ST0 et 0,

Ces instructions changent les bits C0, C1, C2 et C3 du registre de statut du coprocesseur. Malheureusement, il n'est pas possible pour le processeur d'accéder à ces bits directement. Les instructions de branchement conditionnel utilisent le registre FLAGS, pas le registre de statut du coprocesseur.

Cependant, il est relativement simple de transférer les bits du mot de statut dans les bits correspondants du registre FLAGS en utilisant quelques instructions nouvelles :

FSTSW destination stocke le mot de statut du coprocesseur soit dans un mot en mémoire soit dans le registre AX.

SAHF stocke le registre AH dans le registre FLAGS.

LAHF charge le registre AH avec les bits du registre FLAGS.

La Figure 6.6 montre un court extrait de code en exemple.

Image non disponible

Les lignes 5 et 6 transfèrent les bits C0, C1, C2 et C3 du mot de statut du coprocesseur dans le registre FLAGS. Les bits sont transférés de façon à être identiques au résultat d'une comparaison de deux entiers non signés. C'est pourquoi la ligne 7 utilise une instruction JNA.

Les Pentium Pro (et les processeurs plus récents (Pentium II and III)) supportent deux nouveaux opérateurs de comparaison qui modifient directement le registre FLAGS du processeur.

FCOMI source compare ST0 et source. La source doit être un registre du coprocesseur.

FCOMIP source compare ST0 et source, puis retire ST0 de la pile. La source doit être un registre du coprocesseur.

La Figure 6.7 montre une sous-routine qui trouve le plus grand de deux double en utilisant l'instruction FCOMIP. Ne confondez pas ces instructions avec les fonctions de comparaisons avec un entier (FICOM et FICOMP).

Image non disponible

VII-C-2-e. Instructions diverses

Cette section traite de diverses autres instructions fournies par le coprocesseur.

FCHS ST0 = - ST0 change le signe de ST0.

FABS ST0 = |ST0| extrait la valeur absolue de ST0.

FSQRT extrait la racine carrée de ST0.

FSCALE ST0 = ST0 × 2[ST1] multiplie ST0 par une puissance de 2 rapidement. ST1 n'est pas retiré de la pile du coprocesseur.

La Figure 6.8 montre un exemple d'utilisation de cette instruction.

Image non disponible

VII-C-3. Exemple : formule quadratique

Le premier exemple montre comment la formule quadratique peut être codée en assembleur. Souvenez-vous, la formule quadratique calcule les solutions de l'équation :

ax2 + bx + c = 0

La formule donne deux solutions pour x : x1 et x2.

Image non disponible

L'expression sous la racine carrée (b2 − 4ac) est appelée le déterminant. Sa valeur est utile pour déterminer laquelle des trois possibilités suivantes est vérifiée pour les solutions :

1. Il n'y a qu'une seule solution double. b2 − 4ac = 0 ;

2. Il y a deux solutions réelles. b2 − 4ac > 0 ;

3. Il y a deux solutions complexes. b2 − 4ac < 0.

Voici un court programme C qui utilise la sous-routine assembleur :

quadt.c
Sélectionnez
2
3 int quadratic ( double, double, double, double , double );
4
5  int main()
6 {
7 double a,b,c , root1 , root2;
8
9 printf ("Entrez a , b, c : ");
10 scanf("%lf %lf %lf", &a, &b, &c);
11 if ( quadratic ( a , b, c, &root1, &root2 ) )
12 printf (" racines : %.10g %.10g\n", root1, root2 );
13 else
14 printf ("pas de racine réelle \n");
15 return 0;
16 }

Voici la routine assembleur :

quad.asm
Sélectionnez
1 ; fonction quadratic
2 ; trouve les solutions à l'équation quadratique :
3 ; a*x**2 + b*x + c = 0
4 ; prototype C :
5 ; int quadratic( double a, double b, double c,
6 ; double * root1, double *root2 )
7 ; Paramètres :
8 ; a, b, c - coefficients des puissances de l'équation quadratique (voir ci-dessus)
9 ; root1 - pointeur vers un double où stocker la première racine
10 ; root2 - pointeur vers un double où stocker la deuxième racine
11 ; Valeur de retour :
12 ; retourne 1 si des racines réelles sont trouvées, sinon 0
13
14 ?fine a qword [ebp+8]
15 ?fine b qword [ebp+16]
16 ?fine c qword [ebp+24]
17 ?fine root1 dword [ebp+32]
18 ?fine root2 dword [ebp+36]
19 ?fine disc qword [ebp-8]
20 ?fine one_over_2a qword [ebp-16]
21
22 segment .data
23 MinusFour dw -4
24
25 segment .text
26 global _quadratic
27 _quadratic:
28 push ebp
29 mov ebp, esp
30 sub esp, 16 ; alloue 2 doubles (disc & one_over_2a)
31 push ebx ; on doit sauvegarder l'ebx original
32
33 fild word [MinusFour]; pile : -4
34 fld a ; pile : a, -4
35 fld c ; pile : c, a, -4
36 fmulp st1 ; pile : a*c, -4
37 fmulp st1 ; pile : -4*a*c
38 fld b
39 fld b ; pile : b, b, -4*a*c
40 fmulp st1 ; pile : b*b, -4*a*c
41 faddp st1 ; pile : b*b - 4*a*c
42 ftst ; compare avec 0
43 fstsw ax
44 sahf
45 jb no_real_solutions ; si < 0, pas de solution réelle
46 fsqrt ; pile : sqrt(b*b - 4*a*c)
47 fstp disc ; stocke et décale la pile
48 fld1 ; pile : 1,0
49 fld a ; pile : a, 1,0
50 fscale ; pile : a * 2(1,0) = 2*a, 1
51 fdivp st1 ; pile : 1/(2*a)
52 fst one_over_2a ; pile : 1/(2*a)
53 fld b ; pile : b, 1/(2*a)
54 fld disc ; pile : disc, b, 1/(2*a)
55 fsubrp st1 ; pile : disc - b, 1/(2*a)
56 fmulp st1 ; pile : (-b + disc)/(2*a)
57 mov ebx, root1
58 fstp qword [ebx] ; stocke dans *root1
59 fld b ; pile : b
60 fld disc ; pile : disc, b
61 fchs ; pile : -disc, b
62 fsubrp st1 ; pile : -disc - b
63 fmul one_over_2a ; pile : (-b - disc)/(2*a)
64 mov ebx, root2
65 fstp qword [ebx] ; stocke dans *root2
66 mov eax, 1 ; la valeur de retour est 1
67 jmp short quit
68
69 no_real_solutions :
70 mov eax, 0 ; la valeur de retour est 0
71
72 quit :
73 pop ebx
74 mov esp, ebp
75 pop ebp
76 ret

VII-C-4. Exemple : lire un tableau depuis un fichier

Dans cet exemple, une routine assembleur lit des doubles depuis un fichier. Voici un court programme de test en C :

readt.c
Sélectionnez
1 /
2 Ce programme teste la procédure assembleur 32 bits read_doubles().
3 Il lit des doubles depuis stdin (utilisez une redirection pour lire depuis un fichier).
4 /
5 #include <stdio.h>
6 extern int read_doubles( FILE , double , int );
7 #dene MAX 100
8
9 int main()
10 {
11 int i ,n;
12 double a[MAX];
13
14 n = read_doubles(stdin, a , MAX);
15
16 for ( i=0; i < n; i++ )
17 printf ("= %g\n", i, a[i ]);
18 return 0;
19 }

Voici la routine assembleur :

read.asm
Sélectionnez
1 segment .data
2 format db "%lf", 0 ; format pour fscanf()
3,
4 segment .text
5 global _read_doubles
6 extern _fscanf
7
8 ?fine SIZEOF_DOUBLE 8
9 ?fine FP dword [ebp + 8]
10 ?fine ARRAYP dword [ebp + 12]
11 ?fine ARRAY_SIZE dword [ebp + 16]
12 ?fine TEMP_DOUBLE [ebp - 8]
13
14 ;
15 ; fonction _read_doubles
16 ; prototype C :
17 ; int read_doubles( FILE * fp, double * arrayp, int array_size );
18 ; Cette fonction lit des doubles depuis un fichier texte dans un tableau, jusqu'à
19 ; EOF ou que le tableau soit plein.
20 ; Paramètres :
21 ; fp - FILE pointeur à partir duquel lire (doit être ouvert en lecture)
22 ; arrayp - pointeur vers le tableau de double vers lequel lire
23 ; array_size - nombre d'éléments du tableau
24 ; Valeur de retour :
25 ; nombre de doubles stockés dans le tableau (dans EAX)
26
27 _read_doubles:
28 push ebp
29 mov ebp,esp
30 sub esp, SIZEOF_DOUBLE ; définit un double sur la pile
31
32 push esi ; sauve esi
33 mov esi, ARRAYP ; esi = ARRAYP
34 xor edx, edx ; edx = indice du tableau (initialement 0)
35
36 while_loop:
37 cmp edx, ARRAY_SIZE ; edx < ARRAY_SIZE ?
38 jnl short quit ; si non, quitte la boucle
39 ;
40 ; appelle fscanf() pour lire un double dans TEMP_DOUBLE
41 ; fscanf() peut changer edx, donc on le sauvegarde
42 ;
43 push edx ; sauve edx
44 lea eax, TEMP_DOUBLE
45 push eax ; empile &TEMP_DOUBLE
46 push dword format ; emplie &format
47 push FP ; emplie file pointer
48 call _fscanf
49 add esp, 12
50 pop edx ; restaure edx
51 cmp eax, 1 ; fscanf a retourné 1 ?
52 jne short quit ; si non, on quitte la boucle
53
54 ;
55 ; copie TEMP_DOUBLE dans ARRAYP[edx]
56 ; (Les 8 octets du double sont copiés en deux fois 4 octets)
57 ;
58 mov eax, [ebp - 8]
59 mov [esi + 8*edx], eax ; copie des 4 octets de poids faible
60 mov eax, [ebp - 4]
61 mov [esi + 8*edx + 4], eax ; copie des 4 octets de poids fort
62
63 inc edx
64 jmp while_loop
65
66 quit :
67 pop esi ; restaure esi
68
69 mov eax, edx ; stocke la valeur de retour dans eax
70
71 mov esp, ebp
72 pop ebp
73 ret

VII-C-5. Exemple : rechercher les nombres premiers

Ce dernier exemple recherche les nombres premiers, une fois de plus.

Cette implémentation est plus efficace que la précédente. Elle stocke les nombres premiers déjà trouvés dans un tableau et ne divise que par ceux-ci au lieu de diviser par tous les nombres impairs pour trouver de nouveaux premiers.

Une autre différence est qu'il calcule la racine carrée du candidat pour le prochain premier afin de déterminer le moment où il peut s'arrêter de rechercher des facteurs. Il altère le fonctionnement du coprocesseur afin que lorsqu'il stocke la racine sous forme d'entier, il la tronque au lieu de l'arrondir.

Ce sont les bits 10 et 11 du mot de contrôle qui permettent de paramétrer cela. Ces bits sont appelés les bits RC (Rounding Control, contrôle d'arrondi).

S'ils sont tous les deux à 0 (par défaut), le coprocesseur arrondit lorsqu'il convertit vers un entier. S'ils sont tous les deux à 1, le copresseur tronque les conversions en entiers. Notez que la routine fait attention à sauvegarder le mot de contrôle original et à le restaurer avant de quitter.

Voici le programme C pilote :

fprime.c
Sélectionnez
1 #include <stdio.h>
2 #include <stdlib.h>
3 /
4 fonction nd_primes
5 recherche le nombre indiqué de nombres premiers
6 èParamtres :
7 a &#8722; tableau pour contenir les nombres premiers
8 n &#8722; nombre de nombres premiers à trouver
9 /
10 extern void nd_primes( int a , unsigned n );
11
12 int main()
13 {
14 int statut ;
15 unsigned i;
16 unsigned max;
17 int a;
18
19 printf ("Combien de nombres premiers voulez-vous trouver ? ");
20 scanf("%u", &max);
21
22 a = calloc ( sizeof(int ), max);
23
24 if ( a ) {
25
26 nd_primes(a,max);
27
28 / affiche les 20 derniers nombres premiers trouvés /
29 for( i= ( max > 20 ) ? max &#8722; 20 : 0; i < max; i++ )
30 printf ("= %d\n", i+1, a[i]);
31
32 free (a);
33 statut = 0;
34 }
35 else {
36 fprintf ( stderr , "Impossible de  créer un tableau de %u entiers\n", max);
37 statut = 1;
38 }
39
40 return statut ;
41 }

Voici la routine assembleur :

prime2.asm
Sélectionnez
1 segment .text
2 global _find_primes
3 ;
4 ; fonction find_primes
5 ; trouve le nombre indiqué de nombres premiers
6 ; Paramètres :
7 ; array - tableau pour contenir les nombres premiers
8 ; n_find - nombre de nombres premiers à trouver
9 ; Prototype C :
10 ;extern void find_primes( int * array, unsigned n_find )
11 ;
12 ?fine array ebp + 8
13 ?fine n_find ebp + 12
14 ?fine n ebp - 4 ; Nombre de nombres premiers trouvés
15 ?fine isqrt ebp - 8 ; racine du candidat
16 ?fine orig_cntl_wd ebp - 10 ; mot de contrôle original
17 ?fine new_cntl_wd ebp - 12 ; nouveau mot de contrôle
18
19 _find_primes :
20 enter 12,0 ; fait de la place pour les variables locales
21
22 push ebx ; sauvegarde les variables registre éventuelles
23 push esi
24
25 fstcw word [orig_cntl_wd] ; récupère le mot de contrôle courant
26 mov ax, [orig_cntl_wd]
27 or ax, 0C00h ; positionne les bits d'arrondi à 11 (tronquer)
28 mov [new_cntl_wd], ax
29 fldcw word [new_cntl_wd]
30
31 mov esi, [array] ; esi pointe sur array
32 mov dword [esi], 2 ; array[0] = 2
33 mov dword [esi + 4], 3 ; array[1] = 3
34 mov ebx, 5 ; ebx = guess = 5
35 mov dword [n], 2 ; n = 2
36 ;
37 ; Cette boucle externe trouve un nouveau nombre premier à chaque itération qu'il
38 ; ajoute à la fin du tableau. Contrairement au programme de recherche de nombres
39 ; premier précédent, cette fonction ne détermine pas la primauté du nombre en
40 ; divisant par tous les nombres impairs. Elle ne divise que par les nombres
41 ; premiers qu'elle a déjà trouvé (c'est pourquoi ils sont stockés dans un tableau).
42 ;
43 while_limit:
44 mov eax, [n]
45 cmp eax, [n_find] ; while ( n < n_find )
46 jnb short quit_limit
47
48 mov ecx, 1 ; ecx est utilisé comme indice
49 push ebx ; stocke le candidat sur la pile
50 fild dword [esp] ; le charge sur la pile du coprocesseur
51 pop ebx ; retire le candidat de la pile
52 fsqrt ; calcule sqrt(guess)
53 fistp dword [isqrt] ; isqrt = floor(sqrt(quess))
54 ;
55 ; Cette boucle interne divise le candidat (ebx) par les nombres premiers
56 ; calculés précédemment jusqu'à ce qu'il trouve un de ses facteurs premiers
57 ; (ce qui signifie que le candidat n'est pas premier) ou jusqu'à ce que le
58 ; nombre premier par lequel diviser soit plus grand que floor(sqrt(guess))
59 ;
60 while_factor:
61 mov eax, dword [esi + 4*ecx] ; eax = array[ecx]
62 cmp eax, [isqrt] ; while ( isqrt < array[ecx]
63 jnbe short quit_factor_prime
64 mov eax, ebx
65 xor edx, edx
66 div dword [esi + 4*ecx]
67 or edx, edx ; && guess % array[ecx] != 0 )
68 jz short quit_factor_not_prime
69 inc ecx ; essaie le nombre premier suivant
70 jmp short while_factor
71
72 ;
73 ; found a new prime !
74 ;
75 quit_factor_prime :
76 mov eax, [n]
77 mov dword [esi + 4*eax], ebx ; ajoute le candidat au tableau
78 inc eax
79 mov [n], eax ; incrémente n
80
81 quit_factor_not_prime :
82 add ebx, 2 ; essaie le nombre impair suivant
83 jmp short while_limit
84
85 quit_limit:
86
87 fldcw word [orig_cntl_wd] ; restaure le mot de contrôle
88 pop esi ; restaure les variables registre
89 pop ebx
90
91 leave
92 ret

VIII. Structures et C++

VIII-A. Structures

VIII-A-1. Introduction

Les structures sont utilisées en C pour regrouper des données ayant un rapport entre elles dans une variable composite. Cette technique a plusieurs avantages :

1. Cela clarifie le code en montrant que les données définies dans la structure sont intimement liées ;

2. Cela simplifie le passage des données aux fonctions. Au lieu de passer plusieurs variables séparément, elles peuvent être passées en une seule entité ;

3. Cela augmente la localité(32) du code.

Du point de vue de l'assembleur, une structure peut être considérée comme un tableau avec des éléments de tailles variables. Les éléments des vrais tableaux sont toujours de la même taille et du même type. C'est cette propriété qui permet de calculer l'adresse de n'importe quel élément en connaissant l'adresse de début du tableau, la taille des éléments et l'indice de l'élément voulu.

Les éléments d'une structure ne sont pas nécessairement de la même taille (et habituellement, ils ne le sont pas). À cause de cela, chaque élément d'une structure doit être explicitement spécifié et doit recevoir un tag (ou nom) au lieu d'un indice numérique.

En assembleur, on accède à un élément d'une structure d'une façon similaire à l'accès à un élément de tableau. Pour accéder à un élément, il faut connaître l'adresse de départ de la structure et le déplacement relatif de cet un tableau où ce déplacement peut être calculé grâce à l'indice de l'élément, c'est le compilateur qui affecte un déplacement aux éléments d'une structure.

Par exemple, considérons la structure suivante :

Sélectionnez
struct S {

short int x ; / entier sur 2 octets /

int y ; / entier sur 4 octets /

double z ; / ottant sur 8 octets /

};

La Figure 7.1 montre à quoi pourrait ressembler une variable de type S en mémoire.

Image non disponible

Le standard ANSI C indique que les éléments d'une structure sont organisés en mémoire dans le même ordre que celui de leur définition dans le struct. Il indique également que le premier élément est au tout début de la structure (i.e. au déplacement zéro). Il définit également une macro utile dans le fichier d'en-tête stddef.h appelée offsetof(). Cette macro calcule et renvoie le déplacement de n'importe quel élément d'une structure. La macro prend deux paramètres, le premier est le nom du type de la structure, le second est le nom de l'élément dont on veut le déplacement. Donc le résultat de offsetof(S, y) serait 2 d'après la Figure 7.1.

VIII-A-2. Alignement en mémoire

Si l'on utilise la macro offsetof pour trouver le déplacement de y en utilisant le compilateur gcc, on s'aperçoit qu'elle renvoie 4, pas 2 ! Pourquoi ?

Parce que gcc (et beaucoup d'autres compilateurs) aligne les variables sur des multiples de double-mot par défaut.

Souvenez-vous qu'une adresse est sur un multiple de double-mot si elle est divisible par 4.

En mode protégé 32 bits, le processeur lit la mémoire plus vite si la donnée commence sur un multiple de double-mot. La Figure 7.2 montre à quoi ressemble la structure S en utilisant gcc.

Image non disponible

Le compilateur insère deux octets inutilisés dans la structure pour aligner y (et z) sur un multiple de double-mot. Cela montre pourquoi c'est une bonne idée d'utiliser offsetof pour calculer les déplacements, au lieu de les calculer soi-même lorsqu'on utilise des structures en C.

Bien sûr, si la structure est utilisée uniquement en assembleur, le programmeur peut déterminer les déplacements lui-même. Cependant, si l'on interface du C et de l'assembleur, il est très important que l'assembleur et le C s'accordent sur les déplacements des éléments de la structure ! Une des complications est que des compilateurs C différents peuvent donner des déplacements différents aux éléments. Par exemple, comme nous l'avons vu, le compilateur gcc crée une structure S qui ressemble à la Figure 7.2, cependant, le compilateur de Borland créerait une structure qui ressemble à la Figure 7.1. Les compilateurs C fournissent le moyen de spécifier l'alignement utilisé pour les données. Cependant, le standard ANSI C ne spécifie pas comment cela doit être fait et donc, des compilateurs différents procèdent différemment.

Le compilateur gcc a une méthode flexible et compliquée de spécifier l'alignement. Le compilateur permet de spécifier l'alignement de n'importe quel type en utilisant une syntaxe spéciale. Par exemple, la ligne suivante :

Sélectionnez
typedef short int unaligned_int __attribute__((aligned(1)));

définit un nouveau type appelé unaligned_int qui est aligné sur des multiples d'octet (oui, toutes les parenthèses suivant __attribute__ sont nécessaires !) Le paramètre 1 de aligned peut être remplacé par d'autres puissances de 2 pour spécifier d'autres alignements (2 pour s'aligner sur les mots, 4 sur les doubles-mots, etc.). Si l'élément y de la structure était changé en un type unaligned_int, gcc placerait y au déplacement 2. Cependant, z serait toujours au déplacement 8 puisque les doubles sont également alignés sur des doubles-mots par défaut. La définition du type de z devrait aussi être changée pour le placer au déplacement 6.

Le compilateur gcc permet également de comprimer (pack) une structure.

Cela indique au compilateur d'utiliser le minimum d'espace possible pour la structure. La Figure 7.3 montre comment S pourrait être réécrite de cette façon. Cette forme de S utiliserait le moins d'octets possible, soit 14 octets.

Image non disponible

Les compilateurs de Microsoft et Borland supportent tous les deux la même méthode pour indiquer l'alignement par le biais d'une directive #pragma.

Sélectionnez
#pragma pack(1)

La directive ci-dessus indique au compilateur d'aligner les éléments des structures sur des multiples d'un octet (i.e., sans décalage superflu). Le 1 peut être remplacé par 2, 4, 8 ou 16 pour spécifier un alignement sur des multiples de mots, doubles-mots, quadruples-mots ou de paragraphe, respectivement. La directive reste active jusqu'à ce qu'elle soit écrasée par une autre. Cela peut poser des problèmes puisque ces directives sont souvent utilisées dans des fichiers d'en-tête. Si le fichier d'en-tête est inclus avant d'autres fichiers d'en-tête définissant des structures, ces structures peuvent être organisées différemment de ce qu'elles auraient été par défaut. Cela peut conduire à des erreurs très difficiles à localiser. Les différents modules d'un programme devraient organiser les éléments des structures à différents endroits !

Il y a une façon d'éviter ce problème. Microsoft et Borland permettent la sauvegarde de l'état de l'alignement courant et sa restauration. La Figure 7.4 montre comment on l'utilise.

Image non disponible

VIII-A-3. Champs de bits

Les champs de bits permettent de déclarer des membres d'une structure qui n'utilisent qu'un nombre de bits donné. La taille en bits n'a pas besoin d'être un multiple de 8. Un membre champ de bit est défini comme un unsigned int ou un int suivi de deux-points et de sa taille en bits. La Figure 7.5 en montre un exemple.

Image non disponible

Elle définit une variable 32 bits décomposée comme suit :

Image non disponible

Le premier champ de bits est assigné aux bits les moins significatifs du double-mot(33).

Néanmoins, le format n'est pas si simple si l'on observe comment les bits sont stockés en mémoire. La difficulté apparaît lorsque les champs de bits sont à cheval sur des multiples d'octets. Car les octets, sur un processeur little endian seront inversés en mémoire. Par exemple, les champs de bits de la structure S ressembleront à cela en mémoire :

Image non disponible

L'étiquette f2l fait référence aux cinq derniers bits (i.e., les cinq bits les moins significatifs) du champ de bits f2. L'étiquette f2m fait référence aux cinq bits les plus significatifs de f2. Les lignes verticales doubles montrent les limites d'octets. Si l'on inverse tous les octets, les morceaux des champs f2 et f3 seront réunis correctement.

L'organisation de la mémoire physique n'est habituellement pas importante à moins que des données soient transférées depuis ou vers le programme (ce qui est en fait assez courant avec les champs de bits). Il est courant que les interfaces de périphériques matériels utilisent des nombres impairs de bits dont les champs de bits facilitent la représentation.

Un bon exemple est SCSI(34). Une commande de lecture directe pour un périphérique SCSI est spécifiée en envoyant un message de six octets au périphérique selon le format indiqué dans la Figure 7.6.

Image non disponible

La difficulté de représentation en utilisant les champs de bits est l'adresse de bloc logique qui est à cheval sur trois octets différents de la commande. D'après la Figure 7.6, on constate que les données sont stockées au format big endian. La Figure 7.7 montre une définition qui essaie de fonctionner avec tous les compilateurs.

Image non disponible

Les deux premières lignes définissent une macro qui est vraie si le code est compilé avec un compilateur Borland ou Microsoft. La partie qui peut porter à confusion va des lignes 11 à 14. Tout d'abord, on peut se demander pourquoi les champs lba_mid et lba_lsb sont définis séparément et non pas comme un champ unique de 16 bits. C'est parce que les données sont stockées au format big endian. Un champ de 16 bits serait stocké au format little endian par le compilateur. Ensuite, les champs lba_msb et logical_unit semblent être inversés, cependant, ce n'est pas le cas. Ils doivent être placés dans cet ordre. La Figure 7.8 montre comment les champs sont organisés sous la forme d'une entité de 48 bits (les limites d'octets sont là encore représentées par des

lignes doubles).

Image non disponible

Lorsqu'elle est stockée en mémoire au format little endian, les bits sont réarrangés au format voulu (Figure 7.6).

Pour compliquer encore plus le problème, la définition de SCSI_read_cmd ne fonctionne pas correctement avec le C Microsoft. Si l'expression sizeof(SCSI_read_cmd) est évaluée, le C Microsoft renvoie 8 et non pas 6 ! C'est parce que le compilateur Microsoft utilise le type du champ de bits pour déterminer comment organiser les bits. Comme tous les bits sont déclarés comme unsigned, le compilateur ajoute deux octets à la fin de la structure pour qu'elle comporte un nombre entier de doubles-mots. Il est possible d'y remédier en déclarant tous les champs unsigned short. Maintenant, le compilateur Microsoft n'a plus besoin d'ajouter d'octets d'alignement puisque six octets forment un nombre entier de mots de deux octets(35). Les autres compilateurs fonctionnent également correctement avec ce changement. La Figure 7.9 montre une autre définition qui fonctionne sur les trois compilateurs.

Image non disponible

Elle ne déclare plus que deux champs de bits en utilisant le type unsigned char.

Le lecteur ne doit pas se décourager s'il trouve la discussion ci-dessus confuse. C'est confus ! L'auteur trouve souvent moins confus d'éviter d'utiliser des champs de bits en utilisant des opérations niveau bit pour examiner et modifier les bits manuellement.

VIII-A-4. Utiliser des structures en assembleur

Comme nous l'avons dit plus haut, accéder à une structure en assembleur ressemble beaucoup à accéder à un tableau. Prenons un exemple simple, regardons comment l'on pourrait écrire une routine assembleur qui mettrait à zéro l'élément y d'une structure S. Supposons que le prototype de la routine soit :

Sélectionnez
void zero_y( S s_p );

La routine assembleur serait :

Sélectionnez
1 ?fine y_offset 4
2 _zero_y :
3 enter 0,0
4 mov eax, [ebp + 8] ; récupère s_p depuis la pile
5 mov dword [eax + y_offset], 0
6 leave
7 ret

Le C permet de passer une structure par valeur à une fonction. Cependant, c'est une mauvaise idée la plupart du temps. Toutes les données de la structure doivent être copiées sur la pile puis récupérées par la routine. Il est beaucoup plus efficace de passer un pointeur vers la structure à la place.

Le C permet aussi qu'une fonction renvoie une structure. Évidemment, une structure ne peut pas être retournée dans le registre EAX. Des compilateurs différents gèrent cette situation de façon différente. Une situation courante que les compilateurs utilisent est de réécrire la fonction en interne de façon à ce qu'elle prenne un pointeur sur la structure en paramètre. Le pointeur est utilisé pour placer la valeur de retour dans une structure définie en dehors de la routine appelée.

La plupart des assembleurs (y compris NASM) ont un support intégré pour définir des structures dans votre code assembleur. Reportez-vous à votre documentation pour plus de détails.

VIII-B. Assembleur et C++

Le langage de programmation C++ est une extension du langage C. Beaucoup des règles valables pour interfacer le C et l'assembleur s'appliquent également au C++. Cependant, certaines règles doivent être modifiées. De plus, certaines extensions du C++ sont plus faciles à comprendre en connaissant le langage assembleur. Cette section suppose une connaissance basique du C++.

VIII-B-1. Surcharge et décoration de noms

Le C++ permet de définir des fonctions (et des fonctions membres) différentes avec le même nom. Lorsque plus d'une fonction partage le même nom, les fonctions sont dites surchargées. Si deux fonctions sont définies avec le même nom en C, l'éditeur de liens produira une erreur, car il trouvera deux définitions pour le même symbole dans les fichiers objet qu'il est en train de lier. Par exemple, prenons le code de la Figure 7.10.

Image non disponible

Le code assembleur équivalent définirait deux étiquettes appelées _f ce qui serait bien sûr une

erreur. Le C++ utilise le même procédé d'édition de liens que le C, mais évite cette erreur en effectuant une décoration de nom (name mangling) ou en modifiant le symbole utilisé pour nommer une fonction. D'une certaine façon, le C utilise déjà la décoration de nom. Il ajoute un caractère de soulignement au nom de la fonction C lorsqu'il crée l'étiquette pour la fonction. Cependant, il décorera le nom des deux fonctions de la Figure 7.10 de la même façon et produira une erreur. Le C++ utilise un procédé de décoration plus sophistiqué qui produit deux étiquettes différentes pour les fonctions. Par exemple, la première fonction de la Figure 7.10 recevrait l'étiquette _f__Fi et la seconde, _f__Fd, sous DJGPP. Cela évite toute erreur d'édition de liens.

Malheureusement, il n'y a pas de standard sur la gestion des noms en C++ et des compilateurs différents décorent les noms de façon différente.

Par exemple, Borland C++ utiliserait les étiquettes @f$qi et @f$qd pour les deux fonctions de la Figure 7.10. Cependant, les règles ne sont pas totalement arbitraires. Le nom décoré encode la signature de la fonction. La signature d'une fonction est donnée par l'ordre et le type de ses paramètres. Notez que la fonction qui ne prend qu'un argument int a un i à la fin de son nom décoré (à la fois sous DJGPP et Borland) et que celle qui prend un argument double a un d à la fin de son nom décoré. S'il y avait une fonction appelée f avec le prototype suivant :

Sélectionnez
void f ( int x , int y , double z);

DJGPP décorerait son nom en _f__Fiid et Borland en @f$qiid.

Le type de la fonction ne fait pas partie de la signature d'une fonction et n'est pas encodé dans le nom décoré. Ce fait explique une règle de la surcharge en C++. Seules les fonctions dont les signatures sont uniques peuvent être surchargées. Comme on le voit, si deux fonctions avec le même nom et la même signature sont définies en C++, elles donneront le même nom décoré et créeront une erreur lors de l'édition de liens. Par défaut, toutes les fonctions C++ sont décorées, même celles qui ne sont pas surchargées. Lorsqu'il compile un fichier, le compilateur n'a aucun moyen de savoir si une fonction particulière est surchargée ou non, il décore donc tous les noms. En fait, il décore également les noms des variables globales en encodant le type de la variable d'une façon similaire à celle utilisée pour les signatures de fonctions.

Donc, si l'on définit une variable globale dans un fichier avec un certain type puis que l'on essaie de l'utiliser dans un autre fichier avec le mauvais type, l'éditeur de liens produira une erreur. Cette caractéristique du C++ est connue sous le nom de typesafe linking (édition de liens avec respect des types) . Cela crée un autre type d'erreurs, les prototypes inconsistants. Cela arrive lorsque la définition d'une fonction dans un module ne correspond pas avec le prototype utilisé par un autre module. En C, cela peut être un problème très difficile à corriger. Le C ne détecte pas cette erreur. Le programme compilera et sera lié, mais aura un comportement imprévisible, car le code appelant placera sur la pile des types différents de ceux que la fonction attend. En C++ cela produira une erreur lors de l'édition de liens.

Lorsque le compilateur C++ analyse un appel de fonction, il recherche la fonction correspondante en observant les arguments qui lui sont passés(36).

S'il trouve une correspondance, il crée un CALL vers la fonction adéquate en utilisant les règles de décoration du compilateur.

Comme des compilateurs différents utilisent différentes règles de décoration de nom, il est possible que des codes C++ compilés par des compilateurs différents ne puissent pas être liés ensemble. C'est important lorsque l'on a l'intention d'utiliser une bibliothèque C++ précompilée ! Si l'on veut écrire une fonction en assembleur qui sera utilisée avec du code C++, il faut connaître les règles de décoration de nom du compilateur C++ utilisé (ou utiliser la technique expliquée plus bas).

L'étudiant astucieux pourrait se demander si le code de la Figure 7.10 fonctionnera de la façon attendue. Comme le C++ décore toutes les fonctions, alors la fonction printf sera décorée et le compilateur ne produira pas un CALL à l'étiquette _printf. C'est une question pertinente. Si le prototype de printf était simplement placé au début du fichier, cela arriverait. Son prototype est :

Sélectionnez
int printf ( const char , ...);

DJGPP décorerait ce nom en _printf__FPCce (F pour fonction, P pour pointeur, C pour const, c pour char et e pour ellipse). Cela n'appellerait pas la fonction printf de la bibliothèque C standard ! Bien sûr, il doit y avoir un moyen pour que le C++ puisse appeler du code C. C'est très important, car il existe une énorme quantité de vieux code C utile. En plus de permettre l'accès au code hérité de C, le C++ permet également d'appeler du code assembleur en utilisant les conventions de décoration standard du C.

Le C++ étend le mot-clé extern pour lui permettre de spécifier que la fonction ou la variable globale qu'il modifie utilise les conventions C normales.

Dans la terminologie C++, la fonction ou la variable globale utilise une édition de liens C. Par exemple, pour déclarer la fonction printf comme ayant une édition de liens C, utilisez le prototype :

Sélectionnez
extern "C" int printf ( const char , ... );

Cela impose au compilateur de ne pas utiliser les règles de décoration de nom du C++ sur la fonction, mais d'utiliser les règles C à la place. Cependant, en faisant cela, la fonction printf ne peut pas être surchargée. Cela constitue la façon la plus simple d'interfacer du C++ et de l'assembleur, définir une fonction comme utilisant une édition de liens C puis utiliser la convention d'appel C.

Pour plus de facilité, le C++ permet également de définir une édition de liens C sur un bloc de fonctions et de variables globales. Le bloc est indiqué en utilisant les accolades habituelles.

Sélectionnez
extern "C" {

/ variables globales et prototypes des fonctions ayant une édition de liens C /

}

Si l'on examine les fichiers d'en-tête ANSI C fournis avec les compilateurs C/C++ actuels, on trouve ce qui suit vers le début de chaque fichier d'entête :

Sélectionnez
#ifdef __cplusplus

extern "C" {

#endif

Et une construction similaire, vers la fin, contenant une accolade fermante.

Les compilateurs C++ définissent la macro __cplusplus (avec deux caractères de soulignement au début). L'extrait ci-dessus entoure tout le fichier d'en-tête dans un bloc extern "C" si le fichier d'en-tête est compilé en C++, mais ne fait rien s'il est compilé en C (puisqu'un compilateur C génèrerait une erreur de syntaxe sur extern "C"). La même technique peut être utilisée par n'importe quel programmeur pour créer un fichier d'en-tête pour des routines assembleur pouvant être utilisées en C ou en C++.

VIII-B-2. Références

Les références sont une autre nouvelle fonctionnalité du C++. Elles permettent de passer des paramètres à une fonction sans utiliser explicitement de pointeur. Par exemple, considérons le code de la Figure 7.11.

Image non disponible

En fait, les paramètres par référence sont plutôt simples, ce sont des pointeurs. Le compilateur masque simplement ce fait aux yeux du programmeur (exactement de la même façon que les compilateurs Pascal qui implémentent les paramètres var comme des pointeurs). Lorsque le compilateur génère l'assembleur pour l'appel de fonction ligne 7, il passe l'adresse de y. Si l'on écrivait la fonction f en assembleur, on ferait comme si le prototype était(37) :

Sélectionnez
void f ( int xp);

Les références sont juste une facilité qui est particulièrement utile pour la surcharge d'opérateurs. C'est une autre fonctionnalité du C++ qui permet de donner une signification aux opérateurs de base lorsqu'ils sont appliqués à des structures ou des classes. Par exemple, une utilisation courante est de définir l'opérateur plus (+) de façon à ce qu'il concatène les objets string.

Donc, si a et b sont des strings, a + b renverra la concaténation des chaînes a et b. Le C++ appellerait en réalité une fonction pour ce faire (en fait, cette expression pourrait être réécrite avec une notation fonction sous la forme operator +(a,b)). Pour plus d'efficacité, il est souhaitable de passer l'adresse des objets string à la place de les passer par valeur. Sans référence, cela pourrait être fait en écrivant operator +(&a,&b), mais cela imposerait d'écrire l'opérateur avec la syntaxe &a + &b. Cela serait très maladroit et confus. Par contre, en utilisant les références, il est possible d'écrire a + b, ce qui semble très naturel.

VIII-B-3. Fonctions inline

Les fonctions inline sont encore une autre fonctionnalité du C++(38). Les fonctions inline sont destinées à remplacer les macros du préprocesseur qui prennent des paramètres, sources d'erreur. Sourvenez-vous en C, une macro qui élève un nombre au carré ressemble à cela :

Sélectionnez
#dene SQR(x) ((x)(x))

Comme le préprocesseur ne comprend pas le C et ne fait que de simples substitutions, les parenthèses sont requises pour calculer le résultat correct dans la plupart des cas. Cependant, même cette version ne donnerait pas la bonne réponse pour SQR(x++).

Les macros sont utilisées, car elles éliminent la surcharge d'un appel pour une fonction simple. Comme le chapitre sur les sous-programmes l'a démontré, effectuer un appel de fonction implique plusieurs étapes. Pour une fonction très simple, le temps passé à l'appeler peut être plus grand que celui passé dans la fonction ! Les fonctions inline sont une façon beaucoup plus pratique d'écrire du code qui ressemble à une fonction, mais qui n'effectue pas de CALL à un bloc commun. Au lieu de cela, les appels à des fonctions inline sont remplacés par le code de la fonction. Le C++ permet de rendre une fonction inline en plaçant le mot-clé inline au début de sa définition.

Par exemple, considérons les fonctions déclarées dans la Figure 7.12.

Image non disponible

L'appel à la fonction f, ligne 10, est un appel de fonction normal (en assembleur, en supposant que x est à l'adresse ebp-8 et y en ebp-4) :

Sélectionnez
1 push dword [ebp-8]

2 call _f

3 pop ecx

4 mov [ebp-4], eax

Cependant, l'appel à la fonction inline_f, ligne 11 ressemblerait à :

Sélectionnez
1 mov eax, [ebp-8]

2 imul eax, eax

3 mov [ebp-4], eax

Dans ce cas, il y a deux avantages à inliner. Tout d'abord, la fonction inline est plus rapide. Aucun paramètre n'est placé sur la pile, aucun cadre de pile n'est créé puis détruit, aucun branchement n'est effectué. Ensuite, l'appel à la fonction inline utilise moins de code ! Ce dernier point est vrai pour cet exemple, mais ne reste pas vrai dans tous les cas.

La principal inconvénient de l'inlining est que le code inline n'est pas lié et donc le code d'une fonction inline doit être disponible pour tous les fichiers qui l'utilisent. L'exemple de code assembleur précédent le montre. L'appel de la fonction non-inline ne nécessite que la connaissance des paramètres, du type de valeur de retour, de la convention d'appel et du nom de l'étiquette de la fonction. Toutes ces informations sont disponibles par le biais du prototype de la fonction. Cependant, l'utilisation de la fonction inline nécessite la connaissance de tout le code de la fonction. Cela signifie que si n'importe quelle partie de la fonction change, tous les fichiers source qui utilisent la fonction doivent être recompilés. Souvenez-vous que pour les fonctions non inline, si le prototype ne change pas, souvent les fichiers qui utilisent la fonction n'ont pas besoin d'être recompilés. Pour toutes ces raisons, le code des fonctions inline est généralement placé dans les fichiers d'en-tête. Cette pratique est contraire à la règle stricte habituelle du C selon laquelle on ne doit jamais placer de code exécutable dans les fichiers d'en-tête.

VIII-B-4. Classes

Une classe C++ décrit un type d'objet. Un objet possède à la fois des membres données et des membres fonctions(39). En d'autres termes, il s'agit d'une struct à laquelle sont associées des données et des fonctions. Considérons la classe simple dénie dans la Figure 7.13.

Image non disponible

Une variable de type Simple ressemblerait à une struct C normale avec un seul membre int.

Les fonctions ne sont pas affectées à la structure en mémoire. Cependant, les fonctions membres sont différentes des autres fonctions. On leur passe un paramètre caché. Ce paramètre est un pointeur vers l'objet sur lequel agit la fonction.

En fait, le C++ utilise le mot-clé this pour accéder au pointeur vers l'objet lorsque l'on se trouve à l'intérieur d'une fonction membre.

Par exemple, considérons la méthode set_data de la classe Simple de la Figure 7.13. Si elle était écrite en C, elle ressemblerait à une fonction à laquelle on passerait explicitement un pointeur vers l'objet sur lequel elle agit comme le montre le code de la Figure 7.14.

Image non disponible

L'option -S du compilateur DJGPP (et des compilateurs gcc et Borland également) indique au compilateur de produire un fichier assembleur contenant l'équivalent assembleur du code. Pour DJGPP et gcc le fichier assembleur possède une extension .s et utilise malheureusement la syntaxe du langage assembleur AT&T qui est assez différente des syntaxes NASM et MASM(40) (les compilateurs Borland et MS génèrent un fichier avec l'extension .asm utilisant la syntaxe MASM). La Figure 7.15 montre la sortie de DJGPP convertie en syntaxe NASM avec des commentaires supplémentaires pour clarifier le but des instructions.

Image non disponible

Sur la toute première ligne, notez que la méthode set_data reçoit une étiquette décorée qui encode le nom de la méthode, le nom de la classe et les paramètres.

Le nom de la classe est encodé, car d'autres classes peuvent avoir une méthode appelée set_data et les deux méthodes doivent recevoir des étiquettes différentes. Les paramètres sont encodés afin que la classe puisse surcharger la méthode set_data afin qu'elle prenne d'autres paramètres, comme les fonctions C++ normales. Cependant, comme précédemment, des compilateurs différents encoderont ces informations différemment dans l'étiquette.

Ensuite, aux lignes 2 et 3 nous retrouvons le prologue habituel. À la ligne 5, le premier paramètre sur la pile est stocké dans EAX. Ce n'est pas le paramètre x ! Il s'agit du paramètre caché(41) qui pointe vers l'objet sur lequel on agit.

La ligne 6 stocke le paramètre x dans EDX et la ligne 7 stocke EDX dans le double-mot sur lequel pointe EAX. Il s'agit du membre data de l'objet Simple sur lequel on agit, qui, étant la seule donnée de la classe, est stocké au déplacement 0 de la structure Simple.

VIII-B-4-a. Exemple

Cette section utilise les idées de ce chapitre pour créer une classe C++ qui représente un entier non signé d'une taille arbitraire. Comme l'entier peut faire n'importe quelle taille, il sera stocké dans un tableau d'entiers non signés (doubles-mots). Il peut faire n'importe quelle taille en utilisant l'allocation dynamique. Les doubles-mots sont stockés dans l'ordre inverse(42) (i.e. le double-mot le moins significatif est au déplacement 0). La Figure 7.16 montre la définition de la classe Big_int(43).

Image non disponible
Fig. 7.16 Dénition de la classe Big_int

La taille d'un Big_int est mesurée par la taille du tableau d'unsigned utilisé pour stocker les données.

La donnée membre size_ de la classe est affectée au déplacement 0 et le membre number_ est affecté au déplacement 4.

Pour simplifier l'exemple, seuls les objets ayant des tableaux de la même taille peuvent être additionnés entre eux.

La classe a trois constructeurs : le premier (ligne 9) initialise l'instance de la classe en utilisant un entier non signé normal, le second (ligne 19) initalise l'instance en utilisant une chaîne qui contient une valeur hexadécimale. Le troisième constructeur (ligne 22) est le constructeur par copie.

Cette explication se concentre sur la façon dont fonctionnent les opérateurs d'addition et de soustraction, car c'est là que l'on utilise de l'assembleur.

La Figure 7.17 montre les parties du fichier d'en-tête relatives à ces opérateurs.

Image non disponible

Elles montrent comment les opérateurs sont paramétrés pour appeler des routines assembleur. Comme des compilateurs différents utilisent des règles de décoration radicalement différentes pour les fonctions opérateur, des fonctions opérateur inline sont utilisées pour initialiser les appels aux routines assembleur liées au format C. Cela les rend relativement simples à porter sur des compilateurs différents et est aussi rapide qu'un appel direct.

Cette technique élimine également le besoin de soulever une exception depuis l'assembleur !

Pourquoi l'assembleur n'est-il utilisé qu'ici ? Souvenez-vous que pour effectuer de l'arithmétique en précision multiple, la retenue doit être ajoutée au double mot significatif suivant. Le C++ (et le C) ne permet pas au programmeur d'accéder au drapeau de retenue du processeur. On ne pourrait effectuer l'addition qu'en recalculant indépendamment en C++ la valeur du drapeau de retenue et en l'ajoutant de façon conditionnelle au double-mot suivant. Il est beaucoup plus efficace d'écrire le code en assembleur à partir duquel on peut accéder au drapeau de retenue et utiliser l'instruction ADC qui ajoute automatiquement le drapeau de retenue.

Par concision, seule la routine assembleur add_big_ints sera expliquée ici. Voici le code de cette routine (contenu dans big_math.asm) :

big_math.asm
Sélectionnez
1 segment .text
2 global add_big_ints, sub_big_ints
3 ?fine size_offset 0
4 ?fine number_offset 4
5
6 ?fine EXIT_OK 0
7 ?fine EXIT_OVERFLOW 1
8 ?fine EXIT_SIZE_MISMATCH 2
9
10 ; Paramètres des routines add et sub
11 ?fine res ebp+8
12 ?fine op1 ebp+12
13 ?fine op2 ebp+16
14
15 add_big_ints:
16 push ebp
17 mov ebp, esp
18 push ebx
19 push esi
20 push edi
21 ;
22 ; initialise esi pour pointer vers op1
23 ; edi pour pointer vers op2
24 ; ebx pour pointer vers res
25 mov esi, [op1]
26 mov edi, [op2]
27 mov ebx, [res]
28 ;
29 ; s'assure que les 3 Big_int ont la même taille
30 ;
31 mov eax, [esi + size_offset]
32 cmp eax, [edi + size_offset]
33 jne sizes_not_equal ; op1.size_ != op2.size_
34 cmp eax, [ebx + size_offset]
35 jne sizes_not_equal ; op1.size_ != res.size_
36
37 mov ecx, eax ; ecx = taille des Big_int
38 ;
39 ; initialise les registres pour qu'ils pointent vers leurs tableaux respectifs
40 ; esi = op1.number_
41 ; edi = op2.number_
42 ; ebx = res.number_
43 ;
44 mov ebx, [ebx + number_offset]
45 mov esi, [esi + number_offset]
46 mov edi, [edi + number_offset]
47
48 clc ; met le drapeau de retenue à 0
49 xor edx, edx ; edx = 0
50 ;
51 ; boucle d'addition
52 add_loop:
53 mov eax, [edi+4*edx]
54 adc eax, [esi+4*edx]
55 mov [ebx + 4*edx], eax
56 inc edx ; ne modifie pas le drapeau de retenue
57 loop add_loop
58
59 jc overflow
60 ok_done:
61 xor eax, eax ; valeur de retour = EXIT_OK
62 jmp done
63 overflow:
64 mov eax, EXIT_OVERFLOW
65 jmp done
66 sizes_not_equal:
67 mov eax, EXIT_SIZE_MISMATCH
68 done:
69 pop edi
70 pop esi
71 pop ebx
72 leave
73 ret

Heureusement, la majorité de ce code devrait être compréhensible pour le lecteur maintenant. Les lignes 25 à 27 stockent les pointeurs vers les objets Big_int passés à la fonction via des registres. Souvenez-vous que les références sont des pointeurs. Les lignes 31 à 35 vérifient que les tailles des trois objets sont les mêmes (notez que le déplacement de size_ est ajouté au pointeur pour accéder à la donnée membre). Les lignes 44 à 46 ajustent les registres pour qu'ils pointent vers les tableaux utilisés par leurs objets respectifs au lieu des objets eux-mêmes (là encore, le déplacement du membre number_ est ajouté au pointeur sur l'objet).

La boucle des lignes 52 à 57 additionne les entiers stockés dans les tableaux en additionnant le double-mot le moins significatif en premier, puis les doubles-mots suivants, etc. L'addition doit être effectuée dans cet ordre pour l'arithmétique en précision étendue (voir Section III.A.5).

La ligne 59 vérifie qu'il n'y a pas de dépassement de capacité, lors d'un dépassement de capacité, le drapeau de retenue sera allumé par la dernière addition du double-mot le plus significatif. Comme les doubles-mots du tableau sont stockés dans l'ordre little endian, la boucle commence au début du tableau et avance jusqu'à la fin.

La Figure 7.18 montre un court exemple utilisant la classe Big_int.

Image non disponible

Notez que les constantes de Big_int doivent être déclarées explicitement comme à la ligne 16. C'est nécessaire pour deux raisons. Tout d'abord, il n'y a pas de constructeur de conversion qui convertisse un entier non signé en Big_int. Ensuite, seuls les Big_int de même taille peuvent être additionnés. Cela rend la conversion problématique puisqu'il serait difficile de savoir vers quelle taille convertir. Une implémentation plus sophistiquée de la classe permettrait d'additionner n'importe quelle taille avec n'importe quelle autre. L'auteur ne voulait pas compliquer inutilement cet exemple en l'implémentant ici (cependant, le lecteur est encouragé à le faire).

VIII-B-5. Héritage et polymorphisme▲

L'héritage permet à une classe d'hériter des données et des méthodes d'une autre. Par exemple, considérons le code de la Figure 7.19.

Image non disponible

Il montre deux classes, A et B, où la classe B hérite de A. La sortie du programme est :

Sélectionnez
Taille de a : 4 Déplacement de ad : 0

Taille de b : 8 Déplacement de ad: 0 Déplacement de bd: 4

A::m()

A::m()

Notez que les membres ad des deux classes (B l'hérite de A) sont au même déplacement. C'est important puisque l'on peut passer à la fonction f soit un pointeur vers un objet A soit un pointeur vers un objet de n'importe quel type dérivé de (i.e. qui hérite de) A. La Figure 7.20 montre le code assembleur (édité) de la fonction (généré par gcc).

Image non disponible

Notez que la sortie de la méthode m de A a été produite à la fois par l'objet a et l'objet b. D'après l'assembleur, on peut voir que l'appel à A::m() est codé en dur dans la fonction. Dans le cadre d'une vraie programmation orientée objet, la méthode appelée devrait dépendre du type d'objet passé à la fonction. On appelle cela le polymorphisme. Le C++ désactive cette fonctionnalité par défaut. On utilise le mot-clé virtual pour l'activer. La Figure 7.21 montre comment les deux classes seraient modifiées.

Image non disponible

Rien dans le restet du code n'a besoin d'être changé. Le polymorphisme peut être implémenté de beaucoup de manières. Malheureusement, l'implémentation de gcc est en transition au moment d'écrire ces lignes et devient beaucoup plus compliquée que l'implémentation initiale. Afin de simplifier cette explication, l'auteur ne couvrira que l'implémentation du polymorphisme que les compilateurs Microsoft et Borland utilisent. Cette implémentation n'a pas changé depuis des années et ne changera probablement pas dans un futur proche.

Avec ces changements, la sortie du programme change :

Sélectionnez
Size of a: 8 Déplacement de ad : 4

Size of b: 12 Déplacement de ad : 4 Déplacement de bd : 8

A::m()

B::m()

Maintenant, le second appel à f appelle la méthode B::m(), car on lui passe un objet B. Ce n'est pas le seul changement cependant. La taille d'un A vaut maintenant 8 (et 12 pour B). De plus, le déplacement de ad vaut maintenant 4, plus 0. Qu'y a-t-il au déplacement 0 ? La réponse à cette question est liée à la façon dont est implémenté le polymorphisme.

Une classe C++ qui a une (ou plusieurs) méthode(s) virtuelle(s) a un champ caché qui est un pointeur vers un tableau de pointeurs sur des méthodes(44). Cette table est souvent appelée la vtable. Pour les classes A et B ce pointeur est stocké au déplacement 0. Les compilateurs Windows placent toujours ce pointeur au début de la classe au sommet de l'arbre d'héritage.

En regardant le code assembleur (Figure 7.22) généré pour la fonction f (de la Figure 7.19) dans la version du programme avec les méthodes virtuelles, on peut voir que l'appel à la méthode m ne se fait pas via une étiquette.

Image non disponible

La ligne 9 trouve l'adresse de la vtable de l'objet. L'adresse de l'objet est placée sur la pile ligne 11. La ligne 12 appelle la méthode virtuelle en se branchant à la première adresse dans la vtable(45). Cet appel n'utilise pas d'étiquette, il se branche à l'adresse du code sur lequel pointe EDX. Ce type d'appel est un exemple de liaison tardive (late binding). La liaison tardive repousse le choix de la méthode à appeler au moment de l'exécution du code. Cela permet d'appeler la méthode correspondant à l'objet. Le cas normal (Figure 7.20) code en dur un appel à une certaine méthode et est appelé liaison précoce (early binding), car la méthode est liée au moment de la compilation.

Le lecteur attentif se demandera pourquoi les méthodes de classe de la Figure 7.21 sont explicitement déclarées pour utiliser la convention d'appel C en utilisant le mot-clé __cdecl. Par défaut, Microsoft utilise une convention différente de la convention C standard pour les méthodes de classe C++. Il passe le pointeur sur l'objet sur lequel agit la méthode via le registre ECX au lieu d'utiliser la pile. La pile est toujours utilisée pour les autres paramètres explicites de la méthode. Le modificateur __cdecl demande l'utilisation de la convention d'appel C standard. Borland C++ utilise la convention d'appel C par défaut.

Observons maintenant un exemple légèrement plus compliqué (Figure 7.23).

Image non disponible

Dans celui-là, les classes A et B ont chacune deux méthodes : m1 et m2. Souvenez-vous que comme la classe B ne définit pas sa propre méthode m2, elle hérite de la méthode de classe de A. La Figure 7.24 montre comment l'objet b apparaît en mémoire.

Image non disponible

La Figure 7.25 montre la sortie du programme.

Image non disponible

Tout d'abord, regardons l'adresse de la vtable de chaque objet. Les adresses des deux objets B sont les mêmes, ils partagent donc la même vtable. Une vtable appartient à une classe pas à un objet (comme une donnée membre static). Ensuite, regardons les adresses dans les vtables. En regardant la sortie assembleur, on peut déterminer que le pointeur sur la méthode m1 est au déplacement 0 (ou dword 0) et celui sur m2 est au déplacement 4 (dword 1). Les pointeurs sur la méthode m2 sont les mêmes pour les vtables des classes A et B, car la classe B hérite de la méthode m2 de la classe A.

Les lignes 25 à 32 montrent comment l'on pourrait appeler une fonction virtuelle en lisant son adresse depuis la vtable de l'objet(46). L'adresse de la méthode est stockée dans un pointeur de fonction de type C avec un pointeur this explicite. D'après la sortie de la Figure 7.25, on peut voir comment cela fonctionne. Cependant, s'il vous plaît, n'écrivez pas de code de ce genre ! Il n'est utilisé que pour illustrer le fait que les méthodes virtuelles utilisent la vtable.

Il y a plusieurs leçons pratiques à tirer de cela. Un fait important est qu'il faut être très attentif lorsque l'on écrit ou qu'on lit des variables de type classe depuis un fichier binaire. On ne peut pas utiliser simplement une lecture ou une écriture binaire, car cela lirait ou écrirait le pointeur vtable depuis le fichier ! C'est un pointeur sur l'endroit où la vtable réside dans la mémoire du programme et il varie d'un programme à l'autre. La même chose peut arriver avec les structures en C, mais en C, les structures n'ont des pointeurs que si le programmeur en définit explicitement. Il n'y a pas de pointeurs explicites déclarés dans les classes A ou B.

Une fois encore, il est nécessaire de réaliser que des compilateurs différents implémentent les méthodes virtuelles différemment. Sous Windows, les objets de la classe COM (Component Object Model) utilisent des vtables pour implémenter les interfaces COM(47). Seuls les compilateurs qui implémentent les vtables des méthodes virtuelles comme le fait Microsoft peuvent créer des classes COM. C'est pourquoi Borland utilise la même implémentation que Microsoft et une des raisons pour lesquelles gcc ne peut pas être utilisé pour créer des classes COM.

Le code des méthodes virtuelles est identique à celui des méthodes non virtuelles.

Seul le code d'appel est différent. Si le compilateur peut être absolument sûr de la méthode virtuelle qui sera appelée, il peut ignorer la vtable et appeler la méthode directement (p.e., en utilisant la liaison précoce).

VIII-B-6. Autres fonctionnalités C++

Le fonctionnement des autres fonctionnalités du C++ (p.e., le RunTime Type Information, la gestion des exceptions et l'héritage multiple) dépasse le cadre de ce livre. Si le lecteur veut aller plus loin, The Annotated C++ Reference Manual de Ellis et Stroustrup et The Design and Evolution of C++ de Stroustrup constituent un bon point de départ.

IX. Annexe A : Instructions 80x86

IX-A. Instructions hors virgule flottante

Cette section liste et décrit les actions et les formats des instructions hors virgule flottante de la famille de processeurs Intel 80x86.

Les formats utilisent les abréviations suivantes :

Image non disponible

Elles peuvent être combinées pour les instructions à plusieurs opérandes. Par exemple, le format R,R signifie que l'instruction prend deux opérandes de type registre. Beaucoup d'instructions à deux opérandes acceptent les mêmes opérandes. L'abréviation O2 est utilisée pour représenter ces opérandes : R,R R,M R,I M,R M,I. Si un registre 8 bits ou un octet en mémoire peuvent être utilisés comme opérande, l'abréviation R/M8 est utilisée.

Le tableau montre également comment les différents bits du registre FLAGS sont modifiés par chaque instruction. Si la colonne est vide, le bit correspondant n'est pas modifié du tout. Si le bit est toujours positionné à une valeur particulière, un 1 ou un 0 figure dans la colonne. Si le bit est positionné à une valeur qui dépend des opérandes de l'instruction, un C figure dans la colonne. Enfin, si le bit est modifié de façon indéfinie, un « ? » figure dans la colonne. Comme les seules instructions qui changent le drapeau de direction sont CLD et STD, il ne figure pas parmi les colonnes FLAGS.

Image non disponible
Image non disponible
Image non disponible
Image non disponible
Image non disponible
Image non disponible

IX-B. Instruction en virgule flottante

Dans cette section, la plupart des instructions du coprocesseur mathématique du 80x86 sont décrites. La section description décrit brièvement l'opération effectuée par l'instruction. Pour économiser de la place, il n'est pas précisé si l'instruction décale la pile ou non.

La colonne format indique le type d'opérande pouvant être utilisé avec chaque instruction. Les abréviations suivantes sont utilisées :

Image non disponible

Les instructions nécessitant un Pentium Pro ou supérieur sont signalées par un astérisque (*).

Image non disponible
Image non disponible
(1)
L'indice 2 est utilisé pour indiquer que le nombre est représenté en binaire, pas en
décimal.
(2)
S'il n'est pas clair que le point de départ est important, essayez de convertir l'exemple en partant de la gauche.
(3)
En fait, l'ASCII n'utilise que les 7 bits les plus faibles et donc n'a que 128 valeurs à utiliser.
(4)
Cependant, ils peuvent également utiliser une interface de plus bas niveau au niveau du noyau.
(5)
Les nombres à virgule flottante en simple précision sont équivalents aux variables float en C.
(6)
TWORD définit une zone mémoire de 10 octets. Le coprocesseur virgule flottante utilise ce type de données.
(7)
Le fichier asm_io.inc (et le fichier objet asm_io que requiert asm_io.inc) sont dans les exemples de code à télécharger sur la page web de ce tutoriel, http://www.drpaulcarter.com/pcasm.
(8)
Le Chapitre III parle de ce registre.
(9)
GNU est un projet de Free Software Foundation (http://www.fsf.org).
(10)
http://www.delorie.com/djgpp.
(11)
Il est courant de penser que les fichiers ont un caractère EOF comme dernier caractère. Ce n'est pas vrai !
(12)
La raison de cette nécessité sera exposée plus tard.
(13)
2 est le seul nombre premier pair.
(14)
Cet opérateur est différent des opérateurs && binaire et & unaire !
(15)
Application Programming Interface, Interface pour la Programmation d'Applications
(16)
Signie Portable Operating System Interface for Computer Environments, Interface Portable de Système d'Exploitation pour les Environnements Informatiques. Un standard développé par l'IEEE basé sur UNIX.
(17)
En fait, un paramètre de type mode_t qui est défini comme entier par un typedef.
(18)
En fait, passer d'une représentation à l'autre pour entier consiste à inverser l'ordre des octets ; donc, convertir de little vers big ou de big vers little est la même opération. Donc, ces deux fonctions font la même chose.
(19)
En fait, il est également possible d'empiler des mots, mais en mode protégé 32 bits, il est mieux de ne travailler qu'avec des doubles-mots sur la pile.
(20)
Il est autorisé d'ajouter une constante à un registre lors de l'utilisation de l'adressage indirect. Des expressions plus complexes sont également possibles. Ce sujet est traité dans le chapitre suivant.
(21)
GAS est l'assembleur GNU que tous les compilateurs GNU utilisent. Il utilise la syntaxe AT&T qui est très différente des syntaxes relativement similaires de MASM, TASM et NASM.
(22)
Le compiltateur C Watcom est un exemple qui n'utilise pas les conventions standard par défaut. Voyez les sources exemple pour Watcom pour plus de détails.
(23)
Un programme multithreadé a plusieurs threads d'exécution. C'est-à-dire que le programme lui-même est multitâche.
(24)
Positionner AH à zéro équivaut à supposer implicitement que AL est un nombre non signé. S'il est signé, l'action appropriée serait d'insérer une instruction CBW entre les lignes 6 et 7.
(25)
On peut indiquer une taille, mais elle n'est pas prise en compte par le compilateur.
(26)
Une autre complication est qu'on ne peut pas copier la valeur du registre DS dans ES directement en utilisant une instruction MOV. À la place, la valeur de DS doit être copiée dans un registre universel (comme AX) puis être copié depuis ce registre dans ES, ce qui utilise deux instructions MOV.
(27)
Un préfixe d'instruction n'est pas une instruction, c'est un octet spécial qui est placé avant une instruction de chaîne qui modifie son comportement. D'autres préfixes sont également utilisés pour modifier le segment par défaut des accès mémoire.
(28)
Le type long double de certains compilateurs (comme celui de Borland) utilise cette précision étendue. Par contre, d'autres compilateurs utilisent la double précision à la fois pour les double et long double (c'est autorisé par le C ANSI).
(29)
La seule différence est que pour l'infini et les valeurs indéfinies, l'exposant décalé vaut 7FF et non pas FF.
(30)
La racine d'une fonction est une valeur x telle que f(x) = 0.
(31)
Cependant, le 80486SX n'avait pas de coprocesseur intégré. Il y avait une puce 80487SX pour ces machines.
(32)
Voyez le chapitre sur la gestion de la mémoire virtuelle de n'importe quel livre sur les Systèmes d'Exploitation pour une explication de ce terme.
(33)
En fait, le standard ANSI/ISO C laisse une certaine liberté au compilateur sur la façon d'organiser les bits. Cependant, les compilateurs C courants (gcc, Microsoft et Borland) organisent les champs comme cela.
(34)
Small Computer Systems Interface, un standard de l'industrie pour les disques durs, etc.
(35)
Mélanger différents types de champs de bits conduit à un comportement très étrange ! Le lecteur est invité à tester.
(36)
La correspondance n'a pas à être exacte, le compilateur prendra en compte les correspondances trouvées en transtypant les arguments. Les règles de ce procédé sont en dehors de la portée de ce livre. Consultez un livre sur le C++ pour plus de détails.
(37)
Bien sûr, il faudrait déclarer la fonction avec une édition de liens en C, comme nous en a7vons parlé dans la Section IIX.B.1.
(38)
Les compilateurs supportent souvent cette fonctionnalité comme une extension du C ANSI.
(39)
Souvent appelés fonctions membres en C++ ou plus généralement méthodes.
(40)
Le compilateur gcc inclut son propre assembleur appelé gas. L'assembleur gas utilise la syntaxe AT&T et donc le compilateur produit un code au format gas. Il y a plusieurs sites sur le web qui expliquent les différences entre les formats INTEL et AT&T. Il existe également un programme gratuit appelé a2i (http://membres.multimania.fr/placr/a2i.html), qui passe du format AT&T au format NASM.
(41)
Comme d'habitude, rien n'est caché dans le code assembleur !
(42)
Pourquoi ? Car les opérations d'addition commenceront ainsi toujours par le début du tableau et avanceront.
(43)
Voyez le code source d'exemple pour obtenir le code complet de cet exemple. Le texte ne se réfèrera qu'à certaines parties du code.
(44)
Pour les classes sans méthode virtuelle, les compilateurs C++ rendent toujours la classe compatible avec une structure C normale qui aurait les mêmes données membres.
(45)
Bien sûr, la valeur est déjà dans le registre ECX. Elle y a été placée à la ligne 8 et la ligne 10 pourrait être supprimée et la ligne suivante changée de façon à empiler ECX. Le code n'est pas très efficace, car il a été généré en désactivant les optimisations du compilateur.
(46)
Souvenez-vous, ce code ne fonctionne qu'avec les compilateurs MS et Borland, pas gcc.
(47)
Les classes COM utilisent également la convention d'appel __stdcall, pas la convention C standard.

Les sources présentées sur cette page sont libres de droits et vous pouvez les utiliser à votre convenance. Par contre, la page de présentation constitue une œuvre intellectuelle protégée par les droits d'auteur. Copyright © 2013 Paul A. Carter. Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.

Responsable bénévole de la rubrique Assembleur : Alcatîz -
  • Nous contacter
  • Participez
  • Hébergement
  • Informations légales

Copyright © 2000-2016 - www.developpez.com


Tutoriel Programmer en Assembleur (ASM) avec nasm

  • Recherche avancée
  • Inscription
  • or
  • Login
Login to Your Account
Inscription
Hackademics : Forum de hacking – hackers white hat – cours de securite informatique, apprendre langage python, tutoriels de reverse engineering - édité par vBulletin
  • Forum
    • FAQ
    • Calendrier
    • Forum
      • Marquer les messages comme lus
    • Liens utiles
      • Voir l'équipe du forum
  • Forum
  • Développement
  • Applicatif
  • Autres
  • Tutoriel Programmer en Assembleur (ASM) avec nasm
  1. Les visiteurs n'ont qu'un droit de lecture. Pour obtenir les droits en écriture et ainsi partager avec les membres de la communauté Hackademics, vous devez vous inscrire.
Affiche les résultats de 1 à 9 sur 9

Sujet : Programmer en Assembleur (ASM) avec nasm

  • Outils du sujet
    • Afficher la version imprimable
    • Suivre ce sujet…
  1. 12/12/2011, 21h37 #1
    kallimero
    • Voir le profil
    • Voir les messages
    kallimero est déconnecté
    Hackadémicien Membre arrivant
    Inscrit
    November 2011
    Messages
    39
    Thanks
    5
    Thanked 22 Times in 10 Posts
    Pouvoir de réputation
    56

    Programmer en Assembleur (ASM) avec nasm

    Ceci est un premier jet de moi même et de necromoine, qui recherche des avis quand à la qualité de la rédaction (mais pas de l'orthographe qui sera corrigé ultérieurement) et des explications techniques.

    Programmer en Assembleur

    1) - Introduction

    Le langage d'assemblage, abrégé ASM (soit assembleur, par un abus de langage fortuit que je me permettrait lors ce tutoriel), est le langage le plus bas niveau compréhensible par l'homme. Du temps du pron en pixel art, des bonne grosses disquettes noires, plus brièvement du temps où on se la pétait grave avec quelques Mo de ram, ce langage était l'un des plus utilisés. Mais aujourd'hui avec l'avènement des langages de haut niveau, facilitant la dure tâche du programmeur, une part d'ombre s'est installée. Si bien que les pauvre petits hardcoders dans l'âme se sont réfugiés comme ils le pouvaient pour faire perdurer leur espèce, quitte à forniquer seulement entre eux, créant ainsi une pseudo-élite.

    Comme déclaré plus haut, affirmer que l'on programme en assembleur est une faute impardonnable, qui mériterai flagellation à coups d'orties et de ronces. En réalité l'assembleur est le programme qui réalise la coordination entre les mnémoniques et le binaire correspondant, produisant donc le code machine. Le langage d'assemblage quant à lui est le code produit avant l'étape de l'assembleur, c'est à dire les mnémoniques compréhensibles par les idiots que nous sommes.
    Je l'ai également dit plus haut, je me permettrai de parler d'assembleur, effectuant ainsi l'indigne et inexcusable erreur. Non pas que j'aime me faire fouetter avec des ronces et des orties, mais c'est une formule plus commode et surtout beaucoup plus utilisée.

    Dans ce tutoriel, j'aborderai, ou plutôt je vous ferai assimilez de force, les notions principales de la programmation en langage d'assemblage, en utilisant l'assembleur NASM. Qui exploite la syntaxe Intel.

    La totalité de cet écrit se situera sur un système GNU/Linux, pour ainsi combler le manque de documentation française à ce sujet.

    2) – les outils

    Durant toute notre fulgurante ascension de niveau en assembleur, nous utiliserons différent outils indispensables. Pas seulement un IDE (je vous laisserai relativement libre quant à ce choix), mais aussi et surtout un assembleur, un linker, et bien évidemment un debugger. Ce dernier étant très important. Vous verrez vous même ; vous y passerez plus de temps que sur votre IDE.

    2.1) L'assembleur

    Souvenez-vous mes chères, on en a déjà parlé. Dans cet incroyable tutoriel nous utiliserons l'assembleur nasm (the netwide assembler).
    http://nasm.us/

    2.2 ) Le linker

    Intéressons nous maintenant à un point plus pointu dans la création d'exécutable, le 'linkage' (ce terme n'est pas traduisible en français, j'utiliserais donc ce barbarisme à chaque fois).
    Le linkage est l'étape située après l'assemblage du programme, cette étape consiste à lier les fichiers nécessaire à l'exécutable entre eux, l'exemple le plus simple sont les bibliothèques nécessaire au bon fonctionnement du programme (interface graphique par exemple).

    Il existe deux type de linkage, le premier, s'intitule le linkage statique, qui permet d'intégrer au code de l'application, celui de la bibliothèque, ainsi, votre programme de dépendra d'aucun autre fichier et sera plus facile à distribuer. En contre partie, votre exécutable
    sera plus lourd car il contiendra forcément plus de donnée.

    L'autre type de linkage est, comme vous pouvez le deviner, le linkage dynamique, il fournira un code et un exécutable plus léger, mais il sera nécessaire de distribuer les fichiers de la bibliothèque en même temps que votre programme, le meilleur exemple pour l'illustrer ce linkage sont les DLL de windows, sans lequel les programmes ne fonctionnent pas.

    Ainsi dans le cas d'un linkage statique, lors du lancement de l'application cette dernière est certaines de trouver les fonctions nécessaires à son exécution, tandis que sur un linkage dynamique, l'application doit vérifier la présence des fichiers contenant le code de la bibliothèque nécessaire à son fonctionnement.
    http://www.tenouk.com/ModuleW_files/ccompilerlinker001.png

    Pour linker notre fichier (plus besoin d'expliciter le terme j'espère), nous utiliserons ld, de la famille des GNU binutils, en version 2.22. Soit la dernière à la future lointaine époque d'écriture de ce papier. Si votre version, pour X raison (distrib' pourries mal mise à jour), est obsolète, ne partez pas tout de suite, la différence sera normalement infime, pour l'utilisation que nous en faisons.

    2. 3) Le debugger

    Une légère parenthèse s'impose. Qu'est-ce qu'un debugger ?

    Enfaîte, le concept est assez simple ; un debugger permet, à chaque instant, de voir ce qui ce passe à l'intérieur d'un programme (état des registres, de la mémoire du programme). Littéralement, debugger signifie « qui enlève les bugs »
    Celui-ci vous permettra de résoudre vos erreurs. Et croyez-moi, vous en ferez tout un tas.

    Nous nous servirons du célèbre et réputé gdb, Le GNU DeBugger, qui doit sûrement être de base installé sur votre distribution. Le cas échéant il se trouve inévitablement dans vos dépôts.

    3) – Généralités ennuyeuses

    Certains d'entre vous ont probablement bondis sur leur chaise en lisant le terme mnémoniques dans l'introduction et c'est pour cette raison que nous allons nous attarder sur la signification de ce mot.

    D'après notre cher Wikipédia, ce terme désigne : 'l’ensemble des méthodes permettant de mémoriser par association d’idées' ce que nous résumerons dans notre domaine spécifique par la traduction de données binaires incompréhensibles par l'homme en 'mot clé' qui nous sont plus familier. En assembleur, on retrouve des termes tels que MOV, JNE, JMP qui désignent des actions effectuées par le programme lors de son exécution.

    Une ligne basique d'assembleur en syntaxe Intel, pourrait être résumé comme ceci :

    instruction destination, source

    L'ensemble des instructions disponible de votre microprocesseur est appelé le jeu d'instruction.
    Une instruction pourra être une opération mathématique, un déplacement de valeur (d'une variable à une autre...), etc...

    Imaginons une addition ;

    add eax, 2

    add est l'instruction
    eax est la destination
    2 est la source

    Cette instruction ajoutera donc 2 au registre eax.

    3.1) Les registres

    Il vient sûrement à votre génial cerveau une digne question ; qu'est-ce qu'un registre ?

    Il vient au mien une réponse :
    Un registre est une zone de mémoire interne de votre micro-processeur très rapide. Son coût élevé de fabrication nous empêche d'en posséder à profusion.
    Normalement, votre processeur en contient un petite dizaine.
    Dans ce tutoriel, nous utiliserons des registres 32 bits (soit 4 octets). Dont les principaux : eax, ebx, ecx, edx.
    Tout ces registres sont des registres étendues (le e signifie extented), leurs « équivalent » 16bits, seraient ax, bx, cx, dx.
    Et chacun de ces registre 16 bits peut être subdivisé en 2 registres 8 bits. Al et ah, bl et bh, cl et ch, dl et dh.

    Pour être clair et schématique, ce sont des variables accessibles rapidement, qui vont nous être bien utiles.

    3.2) Les systèmes de numération

    En informatique, votre ordinateur très simple d'esprit, ne comprend que les chiffres, ainsi lorsque vous tapez un texte, celui-ci est compris par la machine sous forme de numéro, que l'on retrouve dans la table ASCII par exemple (http://www.asciitable.com/).

    En nous basant sur cette même table, nous allons étudier les différents systèmes de numération qu'il est nécessaire de connaître avant de continuer la lecture de ce dossier (bien que rien ne vous en empêche, c'est un conseil).

    Intéressons nous tout d'abord à la notation décimal, celle que nous utilisons tous les jours, celle de la base 10. La base 10, qu'est ce que c'est ? On appelle base, le nom de caractères différents qui constituent un 'alphabet', c'est à dire qu'a partir des caractères qui composent la base, vous pouvez obtenir n'importe quel caractère. La base 10 s'appelle ainsi car comme vous pouvez le deviner, elle est composée de 10 nombre principaux, c'est à dire de 0 à 9 (et oui, 10 est composé d'un 1 et d'un 0 [image] ). Je ne détaillerais pas plus cette base car si vous avez les compétences pour lire ces lignes, alors vous maîtrisez probablement la base 10.

    Le second système de numération dont nous aurons besoin est celui de la base 16 qui correspond au système hexadécimal, composé de 16 caractères, qui sont les suivants (dans l'ordre croissant): 1,2,3,4,5,7,8,9,A,B,C,D,E,F, ainsi la valeur correspondante à 10 en système décimal s'écrirait A en hexadécimal, que l'on retrouve dans la numérotation des offsets par exemple (que nous étudierons plus loin).

    Le troisième et dernier système de numérotation que nous aborderons ici est celui du binaire, celui de la base 2 composé uniquement de deux nombres : le 0 et le 1. Ce système de numérotation est celui utilisé par votre ordinateur lorsque qu'il lit les instructions, je ne détaille pas plus ces systèmes de numérotation, car nous aurons l'occasion d'en reparler au fur et à mesure de notre dossier.

    Pour résumer, je pense que ce tableau est très pratique :
    http://www.positron-libre.com/cours/electronique/systeme-numeration/images/systeme-numeration-hexadecimal.gif

    Vous utiliserez ces bases à profusion, autant bien les maîtriser.

    3.3)- Les sections

    Tout fichier binaire est divisé en un certain nombre de sections. Pouvant contenir différents éléments comme du code, des données initialisées ou non-initialisées...
    La langage d'assemblage étant très proche du langage binaire, la subdivision sera donc visible et décrite dans votre code.
    Voyons les principales :

    La section data dans laquelle vous pourrez mettre les variables dont la valeur est connue avant la compilation (des constantes par exemple).

    La section bss qui vous permettra de réserver de l'espace mémoire pour une variable que vous utiliserez ultérieurement.

    La section text qui contiendra votre code.

    Ce qui nous permet de dégager une armature typique d'un programme asm :

    section .data
    ; vos données initialisées

    section .bss
    ; vos données non-initialisées

    section .code
    ; votre code

    Le « ; » représente les commentaires (et non la fin d'une instruction).

    3.4)- Les syscalls

    Un syscall (abréviation de system call), est, comme son nom l'indique, un appel au système, afin de lui faire effectuer une tâche définie. Cette tâche peut être de lire du texte, d'en écrire, de lire un fichier, d'exécuter un programme, etc...
    En bref une panoplie de tâches rudimentaires, qui constituent la base de toute – ou quasiment toute – les tâches du système.

    Si vous avez déjà des connaissances basiques en programmation vous connaissez le principe des fonctions. Si vous n'avez aucune connaissance en programmation, je pense qu'il serai préférable de s'atteler à un langage un peu moins agressif que celui-ci, histoire de se familiariser avec les notions fondamentales de la programmation.

    Rappelons qu'une fonction s'utilise afin d'effectuer une tâche précise, en lui donnant parfois des arguments, soit des données, qui lui serviront pour l'effectuer.

    Un syscall est donc une fonction, banale dans son utilisation, un peu différente dans sa structure, qui utilise différent arguments.

    Le numéro de la tâche à effectuer se met dans le registre eax.
    Le premier argument dans ebx, le second dans ecx, le troisième dans edx, etc...
    Puis on appelle le kernel, via le « kernel interrupt », int 80h

    L'instruction qui servira à entrer une valeur dans un registre est l'instruction mov

    On fera donc :

    mov eax, [numéro du syscall]
    mov ebx [premier argument]
    […]
    int 80h

    La valeur de retour du syscall se trouvera dans eax.

    La correspondance entre numéro de syscall et fonction peut être retrouvée ici :
    http://bluemaster.iu.hio.no/edu/dark/lin-asm/syscalls.html
    Ou dans le fichier /usr/include/asm/unistd.h

    Prenons un exemple simple ; écrire du texte.
    Le numéro du syscall write est 4.
    Le numéro de la sortie standard (votre écran) est 1, et il doit être le premier argument.
    La chaîne à écrire doit être le second argument.
    La taille de la chaîne donc être le troisième.

    Avec ce que nous avons déjà vu, vous devriez être capable d'esquisser le code de ce syscall :

    mov eax, 4
    mov ebx, 1
    mov ecx, chaine
    mov edx, TailleChaine
    int 80h

    Second exemple, que vous devrez utiliser dans ton vos programmes, le syscall exit, pour terminer le programme.
    Son numéro est le 1
    un seul argument ; le code d'erreur. 0 signifie qu'il n'y a pas d'erreurs.

    Soit ;
    mov eax, 1
    mov ebx, 0
    int 80h

    Allez, finis le théorique, passons à la pratique.

    4) - Hello World

    Une fois n'est pas coutume, commençons notre périple par une humble salutation internationale.

    Nous avons déjà vu la décomposition des deux syscalls utile pour ce que nous voulons faire (et oui, les exemples n'étaient pas pris au hasard), Il nous suffira de déclarer la chaîne et sa taille dans la section data, et d'utiliser ces syscalls.

    section .data
    chaine: db "hello world !", 10, 13
    tailleChaine: equ $-chaine

    section .text
    global _start

    _start:
    mov eax, 4
    mov ebx, 1
    mov ecx, chaine
    mov edx, tailleChaine
    int 80h

    mov eax, 1
    mov ebx, 0
    int 80h

    3 nouvelles choses :
    Pour déclarer une donnée, on utilise son nom, suivi de deux points, de son « type », et de sa valeur.
    chaine: db "hello world !", 10, 13
    Ici nous déclarons variable nommée chaîne, de valeur "hello world !", 10, 13
    10 et 13 sont les valeurs de CR (Carriage Return) et LF (Line Feed), dans la table ascii, qui représentent un saut de ligne.

    pour déclarer la taille de la chaîne, nous utilisons une petite astuce , plutôt que de l'écrire en dur. Nous utilisons le symbole $, qui signifie le début du programme, au quel on soustrait la variable chaîne (son adresse), ce qui nous renvoi sa taille.

    le global _start représente le point d'entrée du programme (l'oep, Original Entry Point). Là ou il va commencer, et donc le code commence après le _start :

    Pour compiler ce programme il faut d'abord l'assembler avec nasm :

    $ nasm -f elf hello.asm

    Puis le linker avec ld :

    $ ld -o hello hello.o -melf_i386

    Vous pouvez ensuite le lancer :
    $ ./hello
    hello world !

    C'est fait ! Votre premier programme en langage d'assemblage a été exécuté avec succès.
    Dernière édition par kallimero; 12/12/2011 à 21h47
    Répondre en citant Répondre en citant

  2. The Following 5 Users Say Thank You to kallimero For This Useful Post:

    comaX (13/12/2011), le-sorcier (30/08/2012), ptinaze (12/12/2011), SAKAROV (13/12/2011), _47 (13/12/2011)
  3. 13/12/2011, 22h32 #2
    SAKAROV
    • Voir le profil
    • Voir les messages
    • Se rendre sur mon site
    SAKAROV est déconnecté
    Administrateur Membre d'excellence Avatar de SAKAROV
    Inscrit
    July 2011
    Lieu
    Paris
    Âge
    31
    Messages
    3 520
    Thanks
    538
    Thanked 977 Times in 529 Posts
    Pouvoir de réputation
    283
    Génial. Si y'a bien une chose où j'suis vraiment nul (pire encore^^), c'est en ASM !

    Merci !
    SAKAROV

    Cyprium Download Link

    Plus j'étudie plus j'me rends compte que je n'sais rien.

    †|
    Répondre en citant Répondre en citant
  5. 13/12/2011, 23h11 #3
    comaX
    • Voir le profil
    • Voir les messages
    • Se rendre sur mon site
    comaX est déconnecté
    Super Modérateur Membre primordial Avatar de comaX
    Inscrit
    August 2011
    Lieu
    France
    Messages
    961
    Thanks
    184
    Thanked 174 Times in 112 Posts
    Pouvoir de réputation
    115
    Très bien, mais en hexadecimal, comme son nom l'indique si bien, il y a 16 chiffres, et de même que pour la base 10, il y a donc le 0 au début !

    Sinon, très bon article, claire, facile à comprendre. Ca pète !

    Encore une choses, mes connaissances sont limitées, mais tu parles de jeu d'instruction au début. J'imagine que ça fait référence au jeu d'instruction du processeur (MOV, NOP, JNE...). Mais ce jeu est différent entre l'architecture x86 (processeurs 8086) et l'architecture x64 (merci AMD [image]).

    Donc, si je voulais compiler ce programme en compatibilité x86/x64 ou même uniquement pour du x64, comment devrais-je faire ?

    L'assembleur serait-il le même ? Si non, quel devrait-il être ?
    Dernière édition par comaX; 13/12/2011 à 23h36
    http://comax.fr/
    Répondre en citant Répondre en citant
  7. 14/12/2011, 19h05 #4
    kallimero
    • Voir le profil
    • Voir les messages
    kallimero est déconnecté
    Hackadémicien Membre arrivant
    Inscrit
    November 2011
    Messages
    39
    Thanks
    5
    Thanked 22 Times in 10 Posts
    Pouvoir de réputation
    56
    Je fait effectivement bien référence au jeu d'instruction du processeur.
    de l'assembleur 64 bit ne changera pas radicalement du 32 bits. Le plus gros pas en avant est surtout la mise à disposition de plus de registres généraux, et de l'extension de ceux-ci (en 64 bits, donc. Soit rax, rbx, rcx, rdx...).

    Un proco 64bits supportera du 32bits, mais pas l'inverse. Pour compiler avec nasm du 64bits, il suffit de faire nasm -f elf64 fichier.asm, et de linker normalement.
    En revanche si tu veux compiler du 64bits sur une archi 32bits, c'est au linkage que tu auras des problèmes.

    J'espère avoir répondu à tes questions.
    Dernière édition par kallimero; 14/12/2011 à 19h10
    Répondre en citant Répondre en citant
  9. 14/12/2011, 20h01 #5
    comaX
    • Voir le profil
    • Voir les messages
    • Se rendre sur mon site
    comaX est déconnecté
    Super Modérateur Membre primordial Avatar de comaX
    Inscrit
    August 2011
    Lieu
    France
    Messages
    961
    Thanks
    184
    Thanked 174 Times in 112 Posts
    Pouvoir de réputation
    115
    Ouip. Mais ceci dit les elf sont les exécutables linux, comment ferait-on pour windows ? nasm -f exe64 ? (Monsieur pifomètre bonjour).

    Sinon, petit problème : je n'ai pas trouvé d'assembleur 64 bits tout court. Je veux dire par là qu'il a été compilé pour du 32, sans compatibilité, donc il tourne pas (message d'erreur me disant grosso-merdo ça). Une idée ?
    http://comax.fr/
    Répondre en citant Répondre en citant
  11. 14/12/2011, 20h21 #6
    kallimero
    • Voir le profil
    • Voir les messages
    kallimero est déconnecté
    Hackadémicien Membre arrivant
    Inscrit
    November 2011
    Messages
    39
    Thanks
    5
    Thanked 22 Times in 10 Posts
    Pouvoir de réputation
    56
    Si tu souhaites t'initier à nasm sous système windows, je te conseille ce tutoriel : http://esauvage.developpez.com/tutoriels/asm/assembleur-intel-avec-nasm/
    Il y a aura pas mal de différences avec l'assembleur sous GNU/Linux.

    C'est à l'assemblage que tu as ce problème, ou au linkage ?
    Répondre en citant Répondre en citant

  12. The Following User Says Thank You to kallimero For This Useful Post:

    comaX (15/12/2011)
  13. 15/12/2011, 11h18 #7
    comaX
    • Voir le profil
    • Voir les messages
    • Se rendre sur mon site
    comaX est déconnecté
    Super Modérateur Membre primordial Avatar de comaX
    Inscrit
    August 2011
    Lieu
    France
    Messages
    961
    Thanks
    184
    Thanked 174 Times in 112 Posts
    Pouvoir de réputation
    115
    C'est au lancement de nasm. Je le lance dans l'invite de commande, et ça m'affiche un joli message comme quoi il est pas fait pour les version 64bits de Windows...

    Merci pour le tuto, je vais lire ça !
    http://comax.fr/
    Répondre en citant Répondre en citant
  15. 26/12/2011, 23h18 #8
    afranck64
    • Voir le profil
    • Voir les messages
    afranck64 est déconnecté
    Hackadémicien Membre impliqué Avatar de afranck64
    Inscrit
    December 2011
    Messages
    207
    Thanks
    11
    Thanked 13 Times in 11 Posts
    Pouvoir de réputation
    63
    Citation Envoyé par comaX Voir le message
    Ouip. Mais ceci dit les elf sont les exécutables linux, comment ferait-on pour windows ? nasm -f exe64 ? (Monsieur pifomètre bonjour).

    Sinon, petit problème : je n'ai pas trouvé d'assembleur 64 bits tout court. Je veux dire par là qu'il a été compilé pour du 32, sans compatibilité, donc il tourne pas (message d'erreur me disant grosso-merdo ça). Une idée ?
    On ferait plutôt:
    Code:
    nasm -f win64 fichier.asm [...]
    Et puis, linkage.
    Répondre en citant Répondre en citant

  16. The Following 2 Users Say Thank You to afranck64 For This Useful Post:

    comaX (27/12/2011), kallimero (28/12/2011)
  17. 25/06/2012, 00h44 #9
    sonar1
    • Voir le profil
    • Voir les messages
    sonar1 est déconnecté
    Hackadémicien Membre arrivant
    Inscrit
    June 2012
    Lieu
    Marseille
    Messages
    2
    Thanks
    0
    Thanked 0 Times in 0 Posts
    Pouvoir de réputation
    47

    Merci. Je vais le lire tranquillement et m'y remettre..
    Répondre en citant Répondre en citant
Navigation rapide Autres Haut de page
  • Zones du site
  • Mon compte
  • Messages privés
  • Suivis
  • Utilisateurs en ligne
  • Rechercher dans les forums
  • Accueil des forums
  • Forum
  • Accueil
    1. Présentation
      1. Membres
      2. Staff
    2. Informations
      1. Charte
      2. Législation
      3. Disclaimer
      4. Mécanisme du forum
      5. Suggestions d'améliorations
      6. Livre d'or
    3. Liens
      1. Partenaires
      2. Liens utiles
      3. Espace publicitaire
    4. [FAQ] Foire aux Questions
  • News
    1. Forum
    2. Hacking News
    3. Générales
    4. Linux/Windows/Mac
  • Projets
    1. Propositions
    2. Projets en cours
      1. Team#1 : CryptoTool
    3. Demande de Tutoriels
    4. Réservation de tutoriels
  • Orientation (études) - Sécurité informatique
    1. Questions générales (Sécurité)
    2. Études dans le domaine de la sécurité
      1. Bac + 2
      2. Bac + 3
      3. Bac + 5
      4. Bac > 5
    3. Certifications Sécurité Informatique
    4. Conférences Sécurité Informatique
    5. Entreprises (en lien avec la Sécurité Informatique)
  • Système
    1. Système interne
      1. Linux
      2. Mac
      3. Windows
      4. Autres
    2. Mobiles et périphériques
      1. Windows / Windows Phone
      2. Mac / iOS
      3. Linux / Android
      4. Autres
    3. Hardware
      1. PC
      2. Network
      3. Electronics
        1. Phreaking
        2. RFID/NFC
      4. Autres
    4. Serveur
      1. Administration
      2. Hosting
      3. Autres
    5. Autres
  • Développement
    1. Applicatif
      1. C/C++
      2. Python
      3. Bash
      4. Autres
    2. Web/BDD
      1. HTML/CSS
      2. SQL
      3. PHP
      4. Javascript / Ajax
    3. Challenges
    4. Autres
  • Réseau
    1. LAN
      1. Linux
      2. Windows
      3. Mac
    2. WAN
    3. Autres
  • Virtualisation
    1. Système
      1. VMware
        1. Client
        2. Serveur
      2. HyperV
        1. Client
        2. Serveur
      3. VirtualBox
    2. Appliance
  • Cryptologie
    1. Chiffrement
      1. Asymétrique
      2. Symétrique
      3. Quantique
      4. Autres
    2. Tools
    3. Stéganographie
    4. Challenges
    5. Autres
  • Hacking: Connaissances élémentaires
    1. Premiers pas
      1. Les bases du hacking
      2. Vocabulaire
      3. Social engineering
      4. Autres
    2. Anonymat & sécurité
      1. Linux
      2. Windows
      3. Mac
    3. Challenges
  • Hacking: Connaissances avancées
    1. Virologie
      1. Notions théoriques
      2. Malware reverse
        1. Tutoriels
        2. Tools
      3. Autres
    2. Local
      1. MITM
      2. Scanning
      3. Privilege escalation
      4. Sniffing
      5. Crack wifi
      6. Autres
    3. Unhash
    4. Reversing
      1. Failles applicatives
      2. Failles OS
      3. Tools
      4. Autres
    5. Remote & Web
      1. Exploits
      2. Failles
      3. Tools
      4. Shell
      5. Autres
    6. Challenges
    7. Lockpicking
    8. Autres
  • Lounge
    1. Humour
      1. Humoristes francophones
      2. News spectacles
      3. Blagues en tout genre
    2. Trolls
      1. Informatique
      2. Autres
    3. Films
      1. Cinéma
      2. Séries
      3. Autres
    4. Musique
      1. Rap / R&B / Reggaeton
      2. House / Electro / Techno
      3. Hard rock / Métal / Hardcore
      4. Autres
    5. E-books
      1. E-books d'informatique
      2. Autres
    6. Autres
      1. Jeux vidéos
      2. Jeux de réflexion
      3. Graphisme/Web
      4. Audio/Vidéo
      5. Esotérique
      6. Erotique
      7. Sports
      8. Autres
« Sujet précédent | Sujet suivant »

Sujets similaires

  1. Apprenez a programmer en C
    Par L3FT0V3R dans le forum E-books d'informatique
    Réponses: 1
    Dernier message: 14/01/2015, 22h45
  2. Programmer en C pour Android
    Par bravej dans le forum C/C++
    Réponses: 4
    Dernier message: 19/10/2014, 11h01
  3. Assembleur - besoin d'explication
    Par Tommy-42 dans le forum Autres
    Réponses: 7
    Dernier message: 19/01/2014, 12h37
  4. Reversing tutorials - level 2bis - L'assembleur
    Par deathbbs dans le forum Autres
    Réponses: 0
    Dernier message: 07/08/2013, 16h06
  5. Apprendre à programmer avec Perl
    Par _47 dans le forum E-books d'informatique
    Réponses: 0
    Dernier message: 10/03/2013, 12h57

Tags pour ce sujet

avec, nasm, programmer

Voir tous les tags

Liens sociaux

Liens sociaux
  • Soumettre à Digg Digg
  • Soumettre à del.icio.us del.icio.us
  • Soumettre à StumbleUpon StumbleUpon
  • Soumettre à Google Google

Règles des messages

  • Vous ne pouvez pas créer de sujets
  • Vous ne pouvez pas répondre aux sujets
  • Vous ne pouvez pas importer de fichiers joints
  • Vous ne pouvez pas modifier vos messages
  • Les BB codes sont Activés
  • Les Smileys sont Activés
  • Le BB code [IMG] est Activé
  • Le code [VIDEO] est Activé
  • Le code HTML est Désactivé

Règles du forum

Projets
Cyprium
Anonymat et loi renseignement
Partenaires
Suivez-nous !
  • Hackademics |
  • Archives |
  • Charte de confidentialité |
  • Conditions d'utilisation |
  • Haut de page
Fuseau horaire : GMT +2. Il est 14h19.
Propulsé par vBulletin™ Version 4.2.0
Copyright © 2016 vBulletin Solutions, Inc. Tous droits réservés.
Version française par vBulletin-Ressources.com
Hackademics © - Tous droits réservés en adéquation avec le Code de la propriété intellectuelle et littéraire
vBulletin Metro Theme by PixelGoose Studio


Introduction à l'Assembleur GNU

Introduction à l'Assembleur GNU Sous un système GNU/Linux sur Intel 80386

Table des matières Plier Déplier

  • I. Introduction
  • II. Définitions préliminaires
    • II-A. Les systèmes de numération
    • II-B. Le code ASCII
    • II-C. Le code BCD
  • III. L'assemblage et l'édition de liens sous Linux
  • IV. Syntaxe de l'assembleur
    • IV-A. Les sections
    • IV-B. Les commentaires
    • IV-C. Les déclarations
    • IV-D. Les symboles
      • Les symboles locaux
      • IV-D-1. Le symbole « . »
    • IV-E. Les constantes
      • Les nombres entiers
      • Les caractères
      • Les chaînes de caractères
    • IV-F. Les expressions
      • Préfixes
      • Infixes
    • IV-G. Syntaxe des instructions 80386 (IA-32)
      • Les opérandes
      • L'ordre des opérandes
      • Les instructions LJMP/LCALL
    • IV-H. Les directives
      • .ascii ”string_1”, ”string_2” …
      • .asciz ”string_1”, ”string_2” …
      • .byte expression_1, expression_2 …
      • .word expression_1, expression_2 …
      • .quad expression_1, expression_2 …
      • .int expression_1, expression_2 …
      • .long expression_1, expression_2 …
      • .fill repeat, size, value
      • .org new-lc, fill
      • .lcomm symbol, length
      • .global symbol
      • .set symbol, expression et .equ symbol, expression
  • V. Exemples de codes assembleur
    • V-A. Exemple 1 : tri à bulles d'une liste d'entiers
      • Déclaration des variables
      • Étape 1 : entrer la liste d'entiers au clavier
      • Étape 2 : convertir les caractères du buffer
      • Étape 3 : trier la liste
      • Étape 4 : afficher la liste triée
    • V-B. Exemple 2 : les sous-programmes en assembleur
      • Le stack frame
      • Les étapes à suivre
    • V-C. Exemple 3 : interfacer du code assembleur et du langage C
      • Le Makefile
    • V-D. Exemple 4 : un programme d'amorçage
      • Le mode réel
      • L'architecture interne du processeur en mode réel
      • L'adressage de la mémoire en mode réel
      • Le BIOS
      • L'IVT : Interrupt Vector Table
      • Un programme d'amorçage minimal
      • Assemblage et édition de liens
  • VI. Références
  • VII. Remerciements

Ce tutoriel va vous donner la description minimale pour coder en assembleur GNU sous un système GNU/Linux en utilisant le jeu d'instructions Intel 80386.

L'auteur

Issam Abdallah Profil Pro

I. Introduction

L'assembleur est un langage dit bas niveau, c'est-à-dire qu'il est très proche du langage machine.

Autrement dit, pour programmer en assembleur vous devez :

  • apprendre une architecture : Intel par exemple ;
  • avoir quelques connaissances basiques sur les systèmes d'exploitation : Linux par exemple ;
  • maîtriser un assembleur : l'assembleur GNU par exemple.

Apprendre une architecture, c'est comprendre le fonctionnement d'un processeur : les registres, l'adressage et l'organisation de la mémoire, les interruptions… et tout ce que vous avez appris dans le cours d'architecture des ordinateurs. Vous avez, sans doute, une idée claire et suffisante sur l'architecture Intel (IA ou x86) pour aborder ce tutoriel. D'autre part, apprendre un assembleur c'est apprendre une syntaxe pour programmer. C'est l'objectif de ce tutoriel !

Le langage assembleur, ou simplement l'assembleur, est une représentation symbolique du langage machine (données binaires et instructions du processeur). Il existe deux syntaxes d'assembleurs :

  • l'assembleur Intel : l'assembleur principal utilisant cette syntaxe est NASM ;
  • l'assembleur AT&T : l'assembleur principal est l'assembleur GNU ou simplement as.

Ce tutoriel va vous donner la description minimale pour coder en assembleur GNU sous un système GNU/Linux en utilisant le jeu d'instructions Intel 80386.

Les codes sources des exemples cités dans ce tutoriel sont téléchargeables sur Image non disponible cette page.

II. Définitions préliminaires

II-A. Les systèmes de numération

Pour être traitée par un circuit numérique (un processeur, une mémoire RAM…), une information doit être convertie en un format adaptable. Pour cela, elle doit être représentée dans un système de numération caractérisé par une base b (b > 1 est un entier). Les systèmes de numération les plus utilisés sont : le décimal (b = 10), le binaire (b = 2), l'octal (b = 8), l'hexadécimal (b = 16).

Tableau 1 : Les systèmes de numération

Décimal | Binaire | Octal | Hexadécimal
0 | 0000 | 0 | 0
1 | 0001 | 1 | 1
2 | 0010 | 2 | 2
3 | 0011 | 3 | 3
4 | 0100 | 4 | 4
5 | 0101 | 5 | 5
6 | 0110 | 6 | 6
7 | 0111 | 7 | 7
8 | 1000 | 10 | 8
9 | 1001 | 11 | 9
10 | 1010 | 12 | A
11 | 1011 | 13 | B
12 | 1100 | 14 | C
13 | 1101 | 15 | D
14 | 1110 | 16 | E
15 | 1111 | 17 | F

On écrit un nombre N = an an−1 … a1 a0 … , a−1 a−2 … a−m dans un système de numération de base b selon l'expression suivante :

N = ∑−m≤i≤n (ai.bi) = an.bn+ an−1.bn−1 … a1.b1 + a0.b0 … , a−1.b−1 + a−2.b−2 … a−m.b−m

Avec i le poids (position) du chiffre (digit) ai dans le nombre N.

Exemples :

  • b = 10 et N = 2193 : 219310 = 2.103 + 1.102 + 9.101 + 3.100 = 2000 + 100 + 90 + 3
    = 2193 ;
  • b = 2 et N = 1011 : 10112 = 1.23 + 0.22 + 1.21 + 1.20 = 8 + 0 + 2 + 1
    = 11 ;
  • b = 16 et N = F3C9 : F3C916 = 15.163 +3.162 +12.161 +9.160 = 61440+768+192+9
    = 62409 ;
  • b = 2 et N = 10,11 : 10,112 = 1.21 + 0.20 + 1.2−1 + 1.2−2 = 2 + 0 + 0,5 + 0,25
    = 2,75.

En fait, nous avons converti les quatre nombres N représentés dans les systèmes de numération de bases b au format décimal.

Il est clair que la base b d'un système de numération est égale au nombre de chiffres (digits) qui peuvent être utilisés pour représenter un nombre. Ainsi, le système binaire utilise deux digits {0, 1}, le système décimal utilise dix digits {0, 1, 2, 3, 4, 5, 6, 7, 8, 9} et ainsi de suite.

D'autre part, pour adapter un système de numération à un circuit numérique, il faut associer un niveau de tension (voltage) à chaque chiffre. Imaginez que nous décidions d'utiliser le système décimal pour construire un circuit numérique. Il faut donc utiliser dix niveaux de tension. Ce qui nous donne un circuit très compliqué. Par contre, le système binaire, ayant la plus faible base (b = 2), est le plus adapté. Il nécessite seulement deux niveaux de tension, par exemple : 0V pour le digit 0 et 5V pour le digit 1. Pour cette raison, les 0 et les 1 sont les deux seuls symboles du langage machine.

II-B. Le code ASCII

Le code ASCII (American Standard Code for Information Interchange) est un code alphanumérique utilisé pour l'échange des informations entre deux ou plusieurs ordinateurs ou entre un ordinateur et ses périphériques.

Le processeur ne comprend pas la représentation humaine des caractères. Il utilise le codage ASCII (une séquence de 0 et 1), pour identifier les différents caractères alphanumériques. Par exemple, lorsque vous appuyez sur la touche « A » de votre clavier, le processeur reçoit son code ASCII.

Le codage ASCII standard utilise 7 bits pour représenter les caractères de l'alphabet (minuscule et majuscule), les chiffres décimaux, les caractères de ponctuation et les caractères de contrôle :

Tableau 2 : La table ASCII standard

Décimal | Caractère | Décimal | Caractère | Décimal | Caractère
0 | NUL | 43 | + | 86 | V
1 | SOH | 44 | , | 87 | W
2 | STX | 45 | - | 88 | X
3 | ETX | 46 | . | 89 | Y
4 | EOT | 47 | / | 90 | Z
5 | ENQ | 48 | 0 | 91 | [
6 | ACK | 49 | 1 | 92 | \
7 | BEL | 50 | 2 | 93 | ]
8 | BS | 51 | 3 | 94 | ^
9 | HT | 52 | 4 | 95 | _
10 | LF | 53 | 5 | 96 | ‘
11 | VT | 54 | 6 | 97 | a
12 | ff | 55 | 7 | 98 | b
13 | CR | 56 | 8 | 99 | c
14 | SO | 57 | 9 | 100 | d
15 | SI | 58 | : | 101 | e
16 | DLE | 59 | ; | 102 | f
17 | DC1 | 60 | < | 103 | g
18 | DC2 | 61 | = | 104 | h
19 | DC3 | 62 | > | 105 | i
20 | DC4 | 63 | ? | 106 | j
21 | NAK | 64 | @ | 107 | k
22 | SYN | 65 | A | 108 | l
23 | ETB | 66 | B | 109 | m
24 | CAN | 67 | C | 110 | n
25 | EM | 68 | D | 111 | o
26 | SUB | 69 | E | 112 | p
27 | ESC | 70 | F | 113 | q
28 | FS | 71 | G | 114 | r
29 | GS | 72 | H | 115 | s
30 | RS | 73 | I | 116 | t
31 | US | 74 | J | 117 | u
32 | Space | 75 | K | 118 | v
33 | ! | 76 | L | 119 | w
34 | ” | 77 | M | 120 | x
35 | # | 78 | N | 121 | y
36 | $ | 79 | O | 122 | z
37 | % | 80 | P | 123 | {
38 | & | 81 | Q | 124 | |
39 | ' | 82 | R | 125 | }
40 | ( | 83 | S | 126 | ∼
41 | ) | 84 | T | 127 | DEL
42 | * | 85 | U |  |

II-C. Le code BCD

Le code BCD, Binary Coded Decimal, est utilisé pour coder des nombres d'une façon relativement proche de la représentation humaine usuelle (en base 10). En BCD, les nombres sont représentés en chiffres décimaux et chacun de ces chiffres est codé sur quatre bits :

Décimal | BCD
0 | 0000
1 | 0001
2 | 0010
3 | 0011
4 | 0100
5 | 0101
6 | 0110
7 | 0111
8 | 1000
9 | 1001

Il est remarquable que les bits 0-3 de chaque code ASCII d'un chiffre décimal sont égaux à son code BCD.

D'autre part, la plupart des ordinateurs stockent les données dans des octets (d'une taille de 8 bits). Deux méthodes communes permettent d'enregistrer les chiffres BCD de quatre bits dans un tel octet :

  • format non compacté, Unpacked BCD : ignorer les quatre bits supplémentaires de chaque octet et leur ajouter quatre bits identiques (0 ou 1) ;
  • format compacté, Packed BCD : enregistrer deux chiffres (4 bits) par octet.

Pour illustrer, supposons qu'on stocke successivement les chiffres décimaux 9 et 1 au format BCD :

  • format non compacté : décimal -> 1 9, binaire -> 0000 0001 0000 1001 ;
  • format compacté : décimal -> 1 9, binaire -> 0001 1001.

III. L'assemblage et l'édition de liens sous Linux

Le code suivant est celui du programme classique permettant d'afficher, sur la console, le message « Hello, World ! » :

Sélectionnez
  1.                     .data
  2. msg :               .asciz "Hello, World !\n"
  3. len = . - msg
  4.                     .bss
  5.                     .text
  6.                     .global _start
  7.
  8. _start :
  9.                      movl $msg,%ecx
  10.                      movl $len,%edx
  11.                      movl $1,%ebx
  12.                      movl $4,%eax
  13.                      int  $0x80           # appel système
  14.
  15. exit :
  16.                      movl $0,%ebx
  17.                      movl $1,%eax
  18.                      int  $0x80

Le code est écrit en assembleur GNU. Il utilise le jeu d'instructions d'un processeur de la famille x86-32. Nous en discuterons dans la section 5Exemples de codes assembleur. Dans cette section, nous allons juste montrer comment le faire tourner sur la machine.

Pour générer un fichier exécutable à partir du code source, nous utiliserons un programme dit assembleur et un programme dit éditeur de liens (linker). Sous un système GNU/Linux, l'assembleur est le programme as et l'éditeur de liens est le programme ld (loader).

Le schéma suivant montre les étapes à suivre pour générer l'exécutable :

Image non disponible

L'assembleur as prend comme entrée le fichier hello.s, ayant obligatoirement l'extension .s, pour générer le fichier objet hello.o. À son tour, l'éditeur de liens va lier les différents morceaux du code objet et affecter à chacun son adresse d'exécution (run-time address) et produire l'exécutable. Celui-ci est au format ELF (Executable Linkable Format). C'est le format des fichiers binaires utilisés par les systèmes UNIX. C'est l'alternative à l'ancien format a.out (assembler output). Notre makefile contiendra les commandes (rules) suivantes :

Sélectionnez
  1. hello : hello.o
  2.         ld -o hello hello.o
  3. hello.o : hello.s
  4.           as -o hello.o hello.s
  5.
  6. clean :
  7.         rm -fv hello.o

Important [1] : Le symbole global _start est le point d'entrée par défaut de l'éditeur de liens. Il symbolise l'adresse à partir de laquelle le processeur commencera le fetch des instructions, lorsque le programme sera chargé en mémoire et exécuté. Si vous choisissez un nom de symbole de votre choix comme point d'entrée, vous devez passer l'option -e ou -entry à l'éditeur de liens.

IV. Syntaxe de l'assembleur

Reprenons le code source du programme hello.s. Il contient trois sections : .data, .bss et .text.

Chaque section contient un ensemble de déclarations et (optionnellement) des lignes de commentaires.

IV-A. Les sections

Un programme écrit en assembleur GNU peut être divisé en trois sections. Trois directives assembleur sont réservées pour déclarer ces sections :

  1. .text (read only) : la section du code. Elle contient les instructions et les constantes du programme. Un programme assembleur doit contenir au moins la section .text ;
  2. .data (read-write) : la section des données (data section). Elle décrit comment allouer l'espace mémoire pour les variables initialisables du programme (variables globales) ;
  3. .bss (read-write) : contient les variables non initialisées. Dans notre code, cette section est vide. On peut donc l'éliminer.

Important [2] : L'ordre des sections dans le code source n'est pas significatif. N'importe quelle section peut être vide !

IV-B. Les commentaires

Il existe deux méthodes pour commenter un code source. Les commentaires écrits entre /* et */ peuvent occuper plusieurs lignes. Un commentaire préfixé par un # ne peut occuper qu'une seule ligne.

IV-C. Les déclarations

Les déclarations peuvent prendre quatre formats :

Sélectionnez
  1.                           .nom directive
  2. label_1 :                 .nom directive attribut
  3. label_2 :
  4.                           expression
  5.                           instruction       op1 , op2 , ...

Une déclaration se termine par le caractère saut de ligne \n ou par le caractère « ; ».

Une déclaration peut commencer par une étiquette (label). Une étiquette peut être suivie d'un symbole clé qui détermine le type de la déclaration. Si le symbole clé est préfixé par un point, alors la déclaration est une directive assembleur. Les attributs d'une directive peuvent être un symbole prédéfini, une constante ou une expression.

IV-D. Les symboles

Un symbole est une séquence de caractères choisis parmi :

  1. Les lettres de l'alphabet : a .. z, A .. Z ;
  2. Les chiffres décimaux : 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 ;
  3. Les caractères : . $.

Important [3] : Un symbole ne doit jamais commencer par un chiffre. De plus, la casse est significative. Ainsi, on peut utiliser les symboles msg, Msg et MSg dans le même programme : ils représentent des locations différentes dans la mémoire.

Important [4] : L'assembleur GNU réserve un ensemble de symboles clés pour les directives.

Une étiquette est un symbole suivi, immédiatement, du caractère « : ». Elle a deux usages :

  1. Si l'étiquette est écrite dans la section du code, alors le symbole représente une valeur du compteur programme (le Program Counter ou le registre EIP du 80386).
    On peut alors utiliser l'étiquette pour se référer dans le programme. On peut par exemple utiliser le nom du symbole de l'étiquette comme opérande de l'instruction jmp pour transférer le contrôle à la portion de code située à l'adresse symbolisée par l'étiquette ;
  2. Si l'étiquette est écrite dans la section de données (.data), alors le symbole représente une adresse dans la zone mémoire des données. Par exemple, le symbole msg représente l'adresse du premier octet (code ASCII) de la chaîne de caractères « Hello, World ! ».

Important [5] : Le nom du symbole d'une étiquette doit être unique dans un programme. Autrement dit, vous ne devez jamais utiliser le même symbole pour représenter deux locations (étiquettes) différentes. Les symboles locaux sont la seule exception.

Les symboles locaux

L'assembleur GNU propose dix noms de symboles locaux : « 0 », « 1 » .. « 9 ». Vous pouvez définir un symbole local en écrivant une étiquette de la forme « N : », avec N = 0, 1 .. 9. Pour se référer au dernier symbole N défini, vous pouvez, par exemple, spécifier « Nb » comme opérande de l'instruction jmp. De même, pour se référer au premier symbole N suivant, vous pouvez spécifier « Nf » comme opérande. La lettre « b » dans « Nb » est l'abréviation du mot anglais backwards. La lettre « f » dans « Nf » est celle du mot forwards.

Sélectionnez
1:
                     ##
                     ##
                     jmp  1b
                     jmp  1f
                     ##
                     ##
1:
                     ##
                     jmp  1b
                     jmp  2f
2:
                     ##
                     ##
2:

La première instruction jmp va transférer le contrôle à la portion de code située à l'adresse symbolisée par la première étiquette « 1 : ». En exécutant le deuxième et le troisième jmp, le processeur va commencer (immédiatement) l'exécution à l'adresse symbolisée par la deuxième étiquette « 1 : ». La quatrième instruction jmp va transférer le contrôle à la portion de code située à l'adresse symbolisée par la première étiquette « 2 : » (troisième étiquette dans le code !).

Vous remarquez, dans l'exemple ci-dessus, qu'on a utilisé le même symbole « 1 » pour représenter deux locations différentes. Ce qui est normalement interdit. Mais ça fonctionnera quand même. En effet, les noms des symboles locaux sont juste des notations utilisées temporairement par le compilateur et le programmeur. Ils seront transformés en d'autres symboles avant d'être utilisés par l'assembleur. Vous pouvez consulter le manuel de l'assembleur GNU pour savoir comment ces symboles seront transformés.

IV-D-1. Le symbole « . »

Le symbole spécial « . » peut être utilisé comme une référence à une adresse au moment de l'assemblage.

Exemple :

Dans le programme de la section 3L'assemblage et l'édition de liens sous Linux, avant d'exécuter l'appel système, le registre EDX doit contenir la taille (le nombre de caractères) len du message. L'expression suivante permet de calculer précisément len :

Sélectionnez
  1. len = . - msg

Ici, le symbole « . » fait référence à l'adresse de l'octet situé juste après le message dans la zone mémoire des données. Donc, len est égale 14. C'est exactement le nombre d'octets de notre message.

IV-E. Les constantes

Les nombres entiers

Un nombre entier peut être représenté dans plusieurs systèmes de numération. L'assembleur GNU en identifie quatre : le binaire, le décimal, l'octal et l'hexadécimal.

  1. Un nombre binaire est un 0b ou 0B suivi de zéro ou plusieurs chiffres binaires {0, 1}.
    Exemple : 0b10011.
  2. Un nombre octal est un 0 suivi de zéro ou plusieurs chiffres octaux {0, 1, 2, 3, 4, 5, 6, 7}.
    Exemple : 09122.
  3. Un nombre décimal ne doit pas commencer par 0. Il contient zéro ou plusieurs chiffres décimaux {0..9}.
    Exemple : 97340.
  4. Un nombre hexadécimal est un 0x ou 0X suivi de zéro ou plusieurs chiffres hexadécimaux {0..9, A, B, C, D, E, F}.
    Exemple : 0x6FC9D.

Les caractères

Comme le montre le , chaque caractère est identifié par son code ASCII. Dans un code assembleur, on définit un caractère en écrivant son code ASCII approprié. En utilisant, la syntaxe de l'assembleur GNU, un caractère peut être écrit comme une apostrophe suivie immédiatement de son symbole. Exemple :

  • 'A est le code ASCII 65 de A ;
  • '9 désigne le code ASCII 100 de 9.

Les chaînes de caractères

Une chaîne de caractères (string) est une séquence de caractères écrite entre guillemets. Elle représente un tableau contigu d'octets en mémoire. Exemple : « Hello, World ! ».

Important [6] : La manière d'obtenir des caractères spéciaux dans une chaîne de caractères est de les échapper en les faisant précéder d'un antislash « \ ».

Exemple :

Dans le programme de la section 3L'assemblage et l'édition de liens sous Linux, nous avons échappé le caractère saut de ligne dans le message. Ainsi, nous avons utilisé un seul appel système pour afficher à la fois le message et effectuer le saut de ligne :

Sélectionnez
msg :               .asciz "Hello, World !\n"

Le caractère n, lorsqu'il est préfixé par un antislash, est équivalent au code ASCII (10) du caractère saut de ligne.

IV-F. Les expressions

Une expression spécifie une adresse ou une valeur numérique. Une expression entière est un ou plusieurs arguments délimités par des opérateurs. Les arguments sont des symboles, des chiffres ou des sous-expressions. Une sous-expression est une expression écrite entre deux parenthèses. Les opérateurs peuvent être des préfixes ou des infixes :

Préfixes

Les opérateurs préfixes prennent un argument absolu. as propose deux opérateurs préfixes :

  • L'opérateur ∼ complément bit-à-bit (bitwise not).
    Exemple : ∼ 0b10001110 = 10001110 = 01110001 ;
  • L'opérateur négation - (complément à deux) : −N = ∼ N + 1.
    Exemple : N = 142 = 0b10001110, −142 = 01110001 + 1 = 01110010.

Infixes

Un opérateur de type infixe prend deux arguments : ∗, /, %, <, <<, >, >>, +, −, == …

Exemples d'expressions :

Sélectionnez
  1. len = . - msg
  2. SYSSIZE = 0x80000
  3. SYSSEG = 0x1000
  4. ENDSEG = SYSSEG + SYSSIZE                            # ENDSEG = 0x81000

IV-G. Syntaxe des instructions 80386 (IA-32)

Les opérandes

  • Les registres : les opérandes de type registre doivent être préfixés par un %.
    Exemples : %eax, %ax, %al, %ah, %ebx, %ecx, %edx, %esp, %ebp, %esi, %edi…
  • Les opérandes immédiats : les opérandes de ce type doivent être préfixés par un $.
    Exemples : $4, $0x79ff03C3, $0b10110, $07621, $msg…
  • Les opérandes mémoire : lorsqu'un opérande réside dans le segment de données du programme, le processeur doit calculer son adresse effective (EA : Effective Address) en se basant sur l'expression suivante :
    EA = base + scale * index + disp.

    L'assembleur GNU utilise la syntaxe AT &T qui traduit l'expression précédente en celle-ci :
    EA = disp (base, index, scale)
    avec :
    - disp : un déplacement facultatif. disp peut être un symbole ou un entier signé ;
    - scale : un scalaire qui multiplie index. Il peut avoir la valeur 1, 2, 4 ou 6. Si le scale n'est pas spécifié, il est substitué par 1 ;
    - base : un registre 32 bits optionnel. Souvent, on utilise EBX et EBP (si l'opérande est dans la pile) comme base ;
    - index : un registre 32 bits optionnel. Souvent, on utilise ESI et EDI comme index.

La taille des opérandes peut être codée dans le dernier caractère de l'instruction : b indique une taille de 8 bits, w indique une taille de 16 bits et l indique une taille de 32 bits.

Exemples :

Sélectionnez
  1. movb $0x4f,%al               # 8-bit
  2. movw $0x4ffc,%ax             # 16-bit
  3. movl $0x9cff83ec,%eax        # 32-bit

L'ordre des opérandes

L'assembleur GNU utilise la syntaxe AT&T. Ainsi, une instruction mov doit être écrite comme suit :

Sélectionnez
  1. mov source,destination

Cet ordre doit être respecté avec le reste des instructions : sub, add…

Les instructions LJMP/LCALL

Les instructions LJMP (Long Jump) et LCALL (Long Call) permettent de transférer le contrôle à un autre programme situé dans un autre segment (autre que celui où elles sont écrites !). La syntaxe de ces deux instructions est :

Sélectionnez
Ljmp        $segment,$offset
lcall       $segment,$offset

Le premier opérande de chaque instruction sera chargé dans le registre CS et le deuxième sera chargé dans le registre EIP (ou IP en mode réel !).

IV-H. Les directives

Les directives sont des pseudo-opérations. Elles sont utilisées pour simplifier, pour le programmeur, quelques opérations complexes telles que l'allocation de la mémoire. Comme on l'a dit dans la section précédente, le nom d'une directive est un symbole clé préfixé par un point.

Il existe plus de 70 directives définies par l'assembleur GNU. Les plus utilisées sont :

.ascii ”string_1”, ”string_2” …

Définit zéro ou plusieurs chaînes de caractères séparées par des virgules.

Sélectionnez
  1. msg1 :                   .ascii "Hello, World !\ n"
  2. bootMsg :                .ascii "Loading system ..."

.asciz ”string_1”, ”string_2” …

Définit zéro ou plusieurs chaînes de caractères séparées par des virgules et dont chacune se termine par le caractère '\0.

Ainsi, ces deux déclarations sont équivalentes :

Sélectionnez
  1. msg1 :                   .ascii "Hello, World !\0"
  2. msg2 :                   .asciz "Hello, World !"

.byte expression_1, expression_2 …

Définit et initialise un tableau d'octets.

Sélectionnez
  1. tab_byte :              .byte 23,'%,0xff,9,'\b

Le premier élément est le nombre 23 et il est situé à l'adresse tab_byte, suivi par le code ASCII du caractère %…

.word expression_1, expression_2 …

Définit et initialise un tableau de mots binaires (16 bits ou word).

Sélectionnez
  1. descrp :                .word    0x07ff
  2.                         .word    0x0000
  3.                         .word    0x9200
  4.                         .word    0x00C0

Important [7] : Avec les processeurs de la famille x86, l'adresse d'un mot, double mot ou d'un quad-mot (64 bits) est l'adresse de son LSB (Least Significant Byte). Ainsi, le quad-mot 0x00C09200000007ff se trouve à l'adresse représentée par descrp. C'est l'adresse de l'octet 0x07ff.

.quad expression_1, expression_2 …

Définit et initialise un tableau de quad-mots.

Sélectionnez
  1. _gdt :                .quad    0x0000000000000000
  2.                       .quad    0x00c09A00000007ff
  3.                       .quad    0x00C09200000007ff
  4.                       .quad    0x0000000000000000

Un .quad est équivalent à quatre .word successifs.

.int expression_1, expression_2 …

Définit et initialise un tableau d'entiers (quatre octets).

Sélectionnez
  1. tab_int :               .int 3*6,16,190,-122

.long expression_1, expression_2 …

Définit et initialise un tableau d'entiers (quatre octets).

Sélectionnez
  1. matrix3_3 :             .long 22,36,9
  2.                         .long 10,91,0
  3.                         .long 20,15,1

.fill repeat, size, value

Réserve repeat adresses contiguës dans la mémoire et les charge avec les valeurs value de taille size.

Sélectionnez
  1. idt :                   .fill 256,8,0

Définit un tableau contenant 256 quad-mots à l'adresse idt. Chaque élément du tableau contient zéro comme valeur.

.org new-lc, fill

Avance le compteur location de la section courante à l'adresse new-lc.

Sélectionnez
  1.            .text
  2.            .global      _start
  3. _start :
  4.            .org 510,0
  5.            .word 0xAA55

Avance le compteur location (registre EIP) de la section texte (code segment) à l'adresse 510 puis écrit le mot 0xAA55. Les octets 0..509 seront chargés par des zéros.

.lcomm symbol, length

Déclare un symbole local et lui attribue length octets sans les initialiser.

Sélectionnez
  1.              .bss
  2.              .lcomm buffer,1024

Important [8] : La directive doit être écrite dans la section .bss.

.global symbol

Déclare un symbole global et visible pour l'éditeur de liens ld.

Sélectionnez
  1.              .global      _start,main

Par défaut, l'éditeur de liens ld reconnaît le symbole global _start comme point d'entrée à l'édition de liens.

Important [9] : Un symbole global déclaré dans un fichier source peut être utilisé dans un autre fichier sans le redéclarer.

.set symbol, expression et .equ symbol, expression

Ces deux directives sont similaires à l'expression : symbol = expression.

Sélectionnez
  1.              .set SYSSIZE,0x80000
  2.              .equ SYSSEG,0x1000

V. Exemples de codes assembleur

V-A. Exemple 1 : tri à bulles d'une liste d'entiers

Le programme tris.s utilise la méthode de tri à bulles pour trier une liste d'entiers positifs entrés au clavier. La liste triée sera, ensuite affichée, sur la console.

Déclaration des variables

Sélectionnez
  1.                           .data
  2. line :                    .byte '\n
  3. space :                   .byte 0x20
  4. msg1 :                    .ascii " << "
  5. len1 = . - msg1
  6. msg2 :                    .ascii " >> "
  7. len2 = . - msg2
  8.                           .bss
  9.                           .lcomm buffer,1024
  10.                           .lcomm list,100
  11.                           .lcomm stack,1024
  • buffer : une mémoire tampon pour stocker temporairement les caractères entrés au clavier ;
  • list : c'est un tableau d'entiers qui peut stocker 25 nombres entiers ;
  • stack : pour réserver 1024 octets dans la pile du programme.

Étape 1 : entrer la liste d'entiers au clavier

Sélectionnez
  1. msg_1 :                             ##
  2.           movl $msg1,%ecx           ## ECX = l'adresse du message
  3.           movl $len1,%edx           ## EDX = la longueur du message en octets
  4.           movl $1,%ebx              ## EBX = file descriptor = 1 => standard output
  5.           movl $4,%eax              ## Appel système 4 : write
  6.           int $0x80                 ## Interruption logicielle (Exception) ID = 0x80
  7.
  8. # Saisir les entiers #############
  9.
  10. kybd :                              ##
  11.           movl $buffer,%ecx         ## ECX = l'adresse du buffer
  12.           movl $1024,%edx           ## EDX = la taille du buffer en octets
  13.           movl $0,%ebx              ## EBX = file descriptor = 0 => standard input
  14.           movl $3,%eax              ## Appel systeme 3 : read
  15.           int $0x80
  16.
  17. # Compter les elements de la liste  ##
  18.
  19.           subl %esi,%esi
  20. 1:
  21.           movl buffer(%esi),%ebx    ## EA = Scale * Index + Displacement
  22.           incl %esi
  23.           cmp %bl,line
  24.           jne 1b

Les registres EAX, EBX, ECX, EDX doivent être initialisés correctement avant l'exécution de l'instruction int $0x80. L'instruction int n génère une exception (interruption logicielle) immédiatement après son exécution.

L'ID (Interrupt Identifier) de l'exception est codé dans l'instruction (n). Le noyau Linux utilise le nombre 128 (0x80) pour identifier les interruptions programmées des autres interruptions (NMI, Divide Error, Trap…). Ainsi, l'instruction int $0x80 (ou int $128) permet à notre programme (application) d'accéder au matériel (clavier, écran…) en exécutant un appel système. Le fichier /usr/include/asm-x86/unistd_32.h liste tous les appels système Linux.

On va utiliser les appels système 1, 3 et 4. Le registre EAX doit explicitement contenir le numéro de l'appel système. Également, le registre EBX doit contenir le descripteur (file descriptor) du périphérique.

Les instructions 10..15 vont charger le buffer avec la liste des caractères (les entiers), en exécutant l'appel système 3 (read). Les entiers entrés au clavier doivent être séparés par un espace (seulement un !).

En exécutant les instructions 17..24, le processeur va calculer le nombre de caractères entrés et le charger dans le registre ESI.

Pour illustrer, supposons qu'on a entré la liste : 19 208 9756 101 229. Le buffer contient 18 caractères :

Image non disponible

Le caractère saut de ligne doit être entré à la fin pour valider la saisie.

Étape 2 : convertir les caractères du buffer

On a dit, au début de ce tutoriel, que l'ordinateur utilise le codage ASCII pour échanger de l'information avec les périphériques d'entrée/sortie. Ainsi, et comme le montre le schéma ci-dessus, la liste des entiers est reçue par l'ordinateur comme un flux (stream) de codes ASCII. Chaque code représente un caractère.

Donc, pour qu'on puisse effectuer des traitements (arithmétiques, comparaisons…) sur des entiers, nous devons d'abord convertir le buffer en une liste d'entiers. C'est l'objectif des lignes de code suivantes :

Sélectionnez
  1.              decl %esi                 ## index dans le buffer
  2.              movl $0,%edi              ## index dans la liste
  3.
  4. new_elem :
  5.              movl $10,%esp             ## pour multiplier par 10
  6.              movl $0,%ebp              ## compter le poids de chaque chiffre
  7.
  8.              subl %eax,%eax            ## EAX = 0
  9.              subl %ecx,%ecx            ## ECX = 0
  10.
  11. new_digit :
  12.              addl %eax,%ecx            ## ECX = ECX + EAX
  13.              subl %eax,%eax
  14.              decl %esi
  15.              cmpl $0,%esi
  16.              jl   1f                   ## si ESI = 0
  17.              movb buffer(%esi),%al     ## charger un nouveau chiffre dans AL
  18.              cmp  $32,%al
  19.              je   if_space             ## si buffer (%esi) = 32 (espace)
  20.              subl $48,%eax             ## chiffre decimal = ascii - 48
  21.              incl %ebp
  22.              cmpl $1,%ebp
  23.              je   new_digit
  24.              movl %ebp,%ebx            ## multiplier EAX n (poids) fois par 10
  25.
  26. mult_10 :
  27.              mul  %esp
  28.              decl %ebx
  29.              cmpl $1,%ebx
  30.              jg   mult_10
  31.              je   new_digit
  32.
  33. if_space :
  34.              movl %ecx,list(,%edi,4)   ## sauvegarder le contenu de ECX
  35.              incl %edi
  36.              jmp new_elem
  37.
  38. 1 :
  39.              movl %ecx,list(,%edi,4)

L'adresse effective d'un caractère dans le buffer est :

EA = disp + base = buffer + %esi = buffer(base, 0, 1) = buffer(%esi)

L'instruction mul utilise implicitement le registre EAX pour stocker le nombre à multiplier. Le résultat sera chargé dans EDX:EAX. Le seul opérande de l'instruction est le multipliant, qui est le contenu (10) du registre ESP dans notre cas.

Étant donné le code ASCII d'un nombre décimal, pour convertir ce code au nombre décimal approprié, on doit soustraire 48.

Exemple : ASCII(3) = 51 = 3 + 48 <==> 3 = ASCII(3) − 48 (voir ).

D'autre part, un nombre décimal est égal à la somme de ses chiffres multipliés, chacun, par 10n. n est le poids de chaque chiffre dans le nombre.

Donc, pour convertir un nombre du buffer représenté par des caractères, on doit d'abord convertir ces caractères en des nombres décimaux. Puis, on doit appliquer la formule de la section II-ALes systèmes de numération. Pour bien fixer les idées, on va prendre comme exemple le nombre 229 (voir le schéma ci-dessus). Pour le convertir, le processeur doit effectuer le calcul suivant :

229 = (50 − 48) ∗ 102 + (50 − 48) ∗ 101 + (57 − 48) ∗ 100 = 2 ∗ 100 + 2 ∗ 10 + 9 ∗ 1

Pour convertir tous les éléments de la liste, le processeur procède comme suit : tant que ESI > 0 :

  • charger l'octet à l'adresse buffer(%esi) dans le registre AL et le comparer à 32 (le code ASCII du caractère SPACE) ;
  • si le contenu de AL est différent de 32, on soustrait 48 de AL. Ensuite, on multiplie le résultat (AL - 48) n fois par 10 (n est le poids du chiffre). Le résultat de la multiplication sera chargé dans EAX. On doit l'ajouter au contenu de ECX, parce que la multiplication utilise implicitement les registres EAX et EDX. ECX doit être nettoyé à chaque fois qu'on rencontre un espace ;
  • un jmp if_space est exécuté si le contenu de AL est égal à 32. ECX contient la somme de tous les chiffres de chaque nombre après la multiplication par 10. Cette somme n'est autre que le code binaire (32 bits) du nombre. On le sauvegarde à l'adresse :
    EA = disp + scale*index = list + %edi * 4 = list( 0 , index, 4 ) = list(,%edi,4)
    Le scale est égal à 4 parce que la taille d'un entier est 4 octets !

Finalement, Le registre EDI contient le nombre d'entiers de la nouvelle liste (list).

Étape 3 : trier la liste

Voir le programme sort.c.

Étape 4 : afficher la liste triée

Pour afficher un élément de la liste, l'ordinateur doit envoyer le code ASCII de chacun de ses chiffres au périphérique de sortie standard (l'écran, fd = 1). D'autre part, les entiers de la liste sont stockés dans la mémoire au format binaire (32 bits). On doit donc les convertir en un flux de caractères. Autrement dit, on doit calculer les codes ASCII (48..57) de chacun de ces chiffres.

C'est l'objectif de cette portion de notre code :

Sélectionnez
  1.            movl  %edi,%esi
  2.
  3. 2 :
  4.            movl  $10,%ebx
  5.            movl  list(,%esi,4),%eax
  6.            movl  $stack,%esp
  7.
  8. 3 :
  9.            movl  $0,%edx
  10.            idivl %ebx
  11.            decl  %esp
  12.            addl  $48,%edx
  13.            movb  %dl,(%esp)
  14.            cmpl  $0,%eax
  15.            jg    3b
  16.            movl  $stack,%edx
  17.            subl  %esp,%edx
  18.            movl  %esp,%ecx
  19.            movl  $1,%ebx
  20.            movl  $4,%eax
  21.            int   $0x80
  22.            movl  $1,%edx
  23.            movl  $space,%ecx
  24.            movl  $1,%ebx
  25.            movl  $4,%eax
  26.            int   $0x80
  27.            decl  %esi
  28.            cmpl  $0,%esi
  29.            jge   2b

Les propriétés de la pile vont nous faciliter cette tâche. Une conversion « décimal-décimal » (division entière par 10) nous permettra d'extraire les chiffres de chaque entier. Pour avoir le code ASCII de chaque chiffre, on doit ajouter 48 à son code obtenu par division entière (divl). Le schéma suivant montre la conversion du nombre 269 :

Image non disponible

Les instructions 22 et 23 permettent de calculer le nombre d'octets empilés (12 octets pour le nombre 269). Le registre EDX doit contenir ce nombre pour exécuter l'appel système. Le registre ECX pointe vers le TOS (Top Of Stack), c'est-à-dire vers le premier chiffre. À l'exécution de l'appel système, le processeur va afficher 2 puis 6 puis 9 (c'est exactement 269) puis un espace.

V-B. Exemple 2 : les sous-programmes en assembleur

Le but de l'exemple somme.s est de montrer le passage des paramètres à travers la pile à un sous-programme. Une telle opération doit suivre quelques règles (conventions) pour rendre efficace la communication entre le programme appelant et le sous-programme appelé.

Le stack frame

Le processeur alloue, dynamiquement, un espace de stockage dans la pile pour chaque sous-programme. Cet espace est appelé stack frame. Il sera complètement libéré au retour du sous-programme.

Image non disponible

Un sous-programme utilise son propre stack frame pour stocker ses variables locales (variables automatiques en C, qui seront perdues au retour du sous-programme !) durant son exécution. Même la fonction principale main() d'un programme possède son propre stack frame !

Les étapes à suivre

Les étapes nécessaires pour invoquer efficacement un sous-programme sont :

  • empiler les paramètres qu'on veut passer au sous-programme ;
  • appeler le sous-programme avec l'instruction call ;
  • enregistrer et mettre à jour le registre EBP, dans le sous-programme :
Sélectionnez
  1.                push %ebp
  2.                movl %esp,%ebp
  • sauvegarder le contexte : empiler le contenu des registres du processeur pour ne pas les perdre durant l'exécution du sous-programme ;
  • exécuter le sous-programme : au cours de cette étape, le registre ESP ne doit pas être modifié. Le registre EBP est utilisé pour se référer aux données dans la pile ;
  • libérer l'espace mémoire alloué aux variables locales, en les dépilant de la pile ;
  • restaurer les anciennes valeurs des registres ;
  • restaurer la valeur de EBP : cette étape va détruire le stack frame. Écrire ces deux lignes de code avant l'instruction RET :
Sélectionnez
  1.                movl %ebp,%esp
  2.                pop %ebp
  • charger l'adresse de retour dans EIP : en exécutant l'instruction RET. Tout simplement, elle va charger le nouveau TOS dans le registre EIP ;
  • nettoyer la pile : en dépilant les paramètres empilés pendant l'étape 1.

Le code somme.c montre comment effectuer un passage par adresse et un passage par valeur en langage C. Cependant, le code somme.s montre ces deux méthodes de passage des paramètres en assembleur GNU.

Ce programme fait appel à la fonction C printf(). L'exemple 3 va expliquer comment le faire.

V-C. Exemple 3 : interfacer du code assembleur et du langage C

Le programme date.s utilise les fonctions C standard scanf et printf pour saisir et afficher la date.

En langage C, les paramètres sont passés entre parenthèses : printf(param,…) et scanf(param,…). Ce n'est pas le cas en assembleur : les paramètres doivent être passés à travers la pile !

L'ordre des paramètres dans la pile est significatif. Ils doivent être passés dans l'ordre suivant :

Adresse | Paramètre
0(%ebp) | l'ancien EBP
4(%ebp) | adresse de retour
8(%ebp) | adresse de format (pformat et sformat)
12(%ebp) | valeur/adresse de la variable jour
16(%ebp) | valeur/adresse de la variable mois
20(%ebp) | valeur/adresse de la variable an

Ainsi, les paramètres doivent être empilés dans l'ordre inverse à celui dans lequel ils sont écrits dans le prototype de la fonction (parm1, parm2…) ! Autrement dit, le dernier paramètre doit être empilé le premier et le premier paramètre doit être empilé le dernier !

Les valeurs de retour de scanf et printf sont retenues dans le registre EAX.

Les étapes 3-9 d'appel des sous-programmes en assembleur sont invisibles, puisque nous ne sommes pas responsables de l'implémentation de la fonction standard. On va juste y faire appel.

Le Makefile

Pour construire l'exécutable à partir d'un code source écrit purement en assembleur, on a utilisé les programmes as et ld. C'est le cas de l'exemple 1. Mais pour assembler et faire l'édition de liens d'un programme assembleur invoquant des fonctions C, on doit utiliser un compilateur. Sous Linux, le compilateur C est le programme gcc.

Sélectionnez
  1. date : date.o
  2.        gcc -gstabs -o date date.o
  3.
  4. date.o : date.s
  5.          gcc -c -o date.o date.s
  6. clean :
  7.         rm -fv date date.o

Le compilateur gcc va transformer (compiler) notre code source (C + assembleur) en un code cible (code assembleur). Puis il va faire appel à as pour assembler et à ld pour construire l'exécutable. Le point d'entrée de ld doit être le symbole global main.

Retour sur l'exemple 1

Le dossier exemple_3 contient un sous-dossier nommé tri. Le code source qu'il contient est une réécriture du programme tri.s de l'exemple 1. On a modifié le programme par l'utilisation des deux fonctions C définies dans le fichier func.c : saisir() et trier() :

  • la fonction saisir(), lorsqu'elle est appelée, va demander à l'utilisateur de saisir (scanf()) une liste d'entiers et de les charger dans un tableau (list) d'entiers ;
  • la fonction trier() va trier la liste d'entiers en utilisant l'algorithme de tri à bulles.

L'utilisation de ces deux fonctions, ainsi que l'utilisation de la fonction C standard printf() pour l'affichage, va nous aider à réduire la taille du programme, en éliminant pas mal d'instructions assembleur. Notre code devient plus simple, plus structuré et donc plus compréhensible.

V-D. Exemple 4 : un programme d'amorçage

Un secteur d'amorçage (Boot Sector) est, généralement, constitué des 512 premiers octets d'un support de stockage (disquette, disque dur, CD/DVD, flash disk) qui peut contenir un programme d'amorçage (Bootstrap program). Pour un CD/DVD, une disquette ou un disque dur, un tel secteur n'est autre que le premier secteur (piste 0, tête 0, secteur 1).

D'autre part, un programme d'amorçage est un programme 16 bits exécutable, qui sera chargé par le programme de démarrage du BIOS, dans la mémoire RAM durant la séquence d'amorçage de l'ordinateur.

À travers cet exemple, on va montrer comment écrire un tel programme et comment le faire tourner sur une machine occupée par un processeur de la famille x86 (x86-32 ou x86-64).

Les codes sources des exemples cités dans cette section sont téléchargeables sur cette page.

Le mode réel

Le 80386 a introduit aux processeurs de la famille x86 la capacité à fonctionner en trois modes : mode protégé, mode virtuel et mode réel (ou mode 8086).

Le mode réel est le mode de fonctionnement du processeur immédiatement après la mise de l'ordinateur sous tension. Dans ce mode, certains registres du processeur sont initialisés, par INTEL, à des valeurs prédéfinies. En outre, la protection, les interruptions et la pagination sont désactivées et l'espace mémoire complet est disponible.

Ainsi, le processeur peut exécuter uniquement des programmes 16 bits. Cet état du processeur est adéquat pour exécuter un programme d'amorçage.

L'architecture interne du processeur en mode réel

En mode réel, un processeur de la famille x86 (x86-32 ou x86-64) exécute uniquement des codes 16 bits conçus pour le 80286 et le 8086. Par conséquent, son architecture interne est presque identique à celle du 8086.

Le tableau suivant montre comment certains registres (le 30486, le Pentium I et tous les processeurs de la famille P6) sont initialisés en mode réel :

Registre | Contenu
EFLAGS | 0x0002
EIP | 0xFFF0
CS | Sélecteur : 0xF000 Base : 0xFFFF0000 Limite : 0xFFFF
DS,SS,ES,FS,GS | Base : 0x00000000 Limite : 0xFFFF
GDTR,IDTR | Base : 0x00000000 Limite : 0xFFFF

Pour plus de détails, jetez un coup d'œil sur le chapitre 9 de ce document.

L'adressage de la mémoire en mode réel

Quelle que soit la taille de la mémoire de votre ordinateur, dans le mode réel le processeur peut adresser seulement 1 Mo + 64 Ko de RAM. Les 64 Ko sont utilisés pour adresser les périphériques d'entrées/sorties. Le processeur peut, alors, seulement manipuler des octets et des mots (16 bits). Cet espace mémoire peut être localisé dans n'importe quelle zone de la mémoire physique, à condition qu'elle ne soit pas réservée par INTEL.

En mode protégé, un processeur de la famille x86 utilise la règle suivante pour calculer une adresse physique :

Adresse linéaire = Adresse physique = Base + Offset

On l'a appelée adresse physique parce qu'en mode réel, la pagination sera désactivée.

D'autre part, en mode réel l'adresse physique est normalement calculée en utilisant cette règle :

adresse logique = offset : base[segment selector]

adresse physique = base x 16 + offset

Cependant, à la réinitialisation du processeur (RESET), l'adresse de base est égale à 0xFFFF0000 (voir le tableau ci-dessus). Ainsi, l'adresse de départ est formée par l'addition de la valeur de l'adresse de base et le contenu du registre EIP (offset). Ainsi :

Adresse physique = 0xFFFF0000 + 0xFFF0 = 0xFFFFFFF0

Juste après la mise sous tension de l'ordinateur, le processeur utilise la première règle pour localiser et exécuter le programme BIOS. Ensuite, le processeur va suivre la règle normale pour la traduction des adresses en mode réel (processeur 8086).

La première instruction qui sera exécutée par le processeur, après RESET, devrait être à l'adresse physique 0xFFFFFFF0. Cette instruction va amener le processeur à exécuter une instruction JMP vers le programme BIOS. Lorsque celui-ci prend le contrôle, il commence par exécuter les tests POST. Une fois ces tests passés avec succès, le programme BIOS va chercher un périphérique amorçable pour démarrer le système.

Le BIOS

Pour une machine compatible avec l'IBM PC, le BIOS (Basic Input Output System) est le premier programme (microcode ou firmware) exécuté par le processeur (en mode réel) juste après la mise sous tension de l'ordinateur. Le programme (les routines) BIOS est stocké dans une mémoire non volatile (ROM) intégrée à la carte mère. Il a deux fonctions principales :

  1. Exécuter les tests POST (Power-On Selft-test) : vérifier l'intégrité du programme BIOS lui-même, tester la RAM, tester et initialiser les autres périphériques de l'ordinateur, initialiser l'IVT (Interrupt Vector Table)… ;
  2. Chercher un périphérique amorçable (bootable) pour démarrer le système (charger le noyau du système en mémoire). C'est en vérifiant, dans l'ordre, le secteur d'amorçage de chaque périphérique connecté à l'ordinateur et configuré dans la liste (du BIOS) des périphériques d'amorçage. Lorsque le BIOS rencontre un secteur d'amorçage, il charge en mémoire le programme stocké dedans, et lui transfère le contrôle.

Pour vérifier si un support de stockage est amorçable ou non, le BIOS teste simplement les deux derniers octets (octets 510 et 511) de son secteur d'amorçage. Si ces deux octets contiennent la valeur 0xAA55 (Boot Signature), alors le BIOS déduira que ce périphérique est amorçable. Alors, il chargera les 512 octets du code à l'adresse physique 0x7C00 de la mémoire et lui transférera le contrôle (JMP 0x7C00).

Enfin, vous trouverez toutes les informations sur les BIOS sur ce site.

L'IVT : Interrupt Vector Table

Le BIOS met à la disposition de tout programme qui s'exécute en mode réel des routines qui vont l'aider à effectuer quelques opérations compliquées, comme l'accès au matériel de l'ordinateur. Ces routines sont appelées les services ou encore les interruptions BIOS.

À ce propos, un tableau de 256 entrées est stocké par le BIOS à l'adresse absolue entre 0x0 et 0x3FF (le premier Kb de la mémoire). Chaque entrée de ce tableau est de quatre octets et contient deux adresses [segment :offset] comme pointeurs vers une routine d'interruption.

Image non disponible

Ces routines sont accessibles via l'instruction int n. Chaque interruption BIOS est identifiée par un nombre n. Le processeur multiplie par 4 le nombre n et utilise le résultat (index) pour localiser, dans l'IVT, le vecteur d'interruption approprié. Le premier mot (16 bits) sera chargé dans le registre CS (segment) et le deuxième sera chargé dans le registre IP (offset).

Ainsi, le processeur va exécuter la routine convenable.

Une entrée dans ce tableau est notée vecteur parce qu'il redirige le processeur pour exécuter une autre portion du code.

Un programme d'amorçage minimal

Le code suivant est celui d'un programme d'amorçage minimal :

Sélectionnez
  1.            .code16
  2.            .data
  3.            .text
  4.            .global _start
  5.
  6. _start :
  7.            .org 510,0
  8.            .word 0xaa55

Un support de stockage, dont le secteur d'amorçage contient le binaire de ce programme, sera certainement amorçable. Mais, comme vous voyez, ce programme ne fera rien à son exécution (un écran noir et vide sera affiché). Il va juste rendre votre support de stockage amorçable. La directive .org a déjà été expliquée. La directive .code16 va demander à l'assembleur d'assembler un code 16 bits (mode réel).

En fait, tout Linuxien a un programme d'amorçage prêt sur son ordinateur. C'est le stage1 du chargeur de boot GRUB. Ce fichier est stocké dans le répertoire /usr/lib/grub/stage1. Son rôle est d'initialiser le processeur au démarrage et de charger en mémoire le noyau GRUB (stage2).

Pour tester un programme d'amorçage, vous pouvez utiliser une machine virtuelle au lieu de redémarrer votre ordinateur plusieurs fois. Le logiciel VirtualBox, par exemple, vous permettra d'en créer une.

En cas de problème de démarrage d'une machine virtuelle sur un flash disk, visitez cette page.

Exemple 1 : hello.s

Sélectionnez
  1.            .code16
  2.            .text
  3.            .global_start
  4. _start :
  5.            mov %cs,%ax           # ax = 0x7c0
  6.            mov %ax,%ds           # ds = 0x7c0
  7.            mov %ax,%es           # es = 0 x7c0
  8.            mov %ax,%ss           # ss = 0 x7c0
  9.            mov $0x100,%ax        # 256 words dans la pile (512 octets)
  10.            mov %ax,%sp           # sp = 256 (TOS)
  11.            mov $0x2f,%bl
  12.            mov $0,%dh            # colonne 0
  13.            mov $0,%dl            # ligne 0
  14.            mov $0x1301,%ax       # afficher une chaîne de caractères
  15.            mov $msg,%bp          # offset du message ES:BP
  16.            mov $13,%cx           # taille du message
  17.            int $0x10
  18. msg :
  19.            .asciz "hello, world !"
  20.            .org 510
  21. signature :
  22.            .word 0xaa55

Comme on l'a dit, au démarrage de l'ordinateur ce programme sera chargé dans la mémoire à l'adresse 0x7C00 et prendra le contrôle. Le registre CS (base) sera chargé avec la valeur 0x7C0 et le contenu de EIP (offset) sera 0. Ainsi, le processeur commencera la recherche des instructions (fetch) et l'exécution à partir de l'adresse physique :

CS x 16 + EIP = 0x07C0 x 0x10 + 0x0000 = 0x7C00

Notre programme possède les mêmes segments de données de pile et de code (DS = SS = ES = CS = 0x7C0). On a utilisé l'interruption 0x10 du BIOS pour afficher notre message. Une liste des interruptions est disponible sur cette page.

Assemblage et édition de liens

Les commandes d'assemblage et d'édition de liens sont :

Sélectionnez
as -o hello.o hello.s
ld --oformat binary -Ttext 7c00 -Tdata 7c00 -o hello hello.o

On a utilisé l'option -oformat pour spécifier à l'éditeur de liens le format binaire du fichier de sortie (hello). Le format binaire doit être choisi. Si on édite les liens sans utiliser cette option, la taille du fichier généré dépassera 512 octets. Quand on passe cette option à l'éditeur de liens, il va générer un fichier de sortie au format raw binary. Autrement dit, il va générer un code machine ayant exactement une taille de 512 octets. Également, les options -Ttext et -Tdata sont utilisées pour indiquer à l'éditeur de liens que les sections de texte et de données doivent être chargées à l'adresse 0x7C00.

La commande suivante permet de désassembler le fichier généré :

Sélectionnez
objdump -b binary -mi8086 -D hello

L'option -b va spécifier le format de fichier d'entrée (binaire). L'option -m va spécifier l'architecture pour laquelle on va désassembler. Nous avons spécifié l'architecture i8086.

Pour charger votre programme de boot sur le secteur d'amorçage de votre flash disk, utilisez le programme dd_rescue.

Exemple 2 : rtc.s

Dans cet exemple, nous allons écrire un programme d'amorçage qui, à son exécution, va lire l'horloge temps réel (RTC) de l'ordinateur en utilisant l'interruption BIOS numéro 0x1A et l'afficher au format hh:mm:ss.

À l'exécution de cette interruption, le processeur va lire l'horloge RTC et stocker sa valeur au format BCD compacté (Packed BCD), dans les registres CX et BX :

Image non disponible

Pour afficher l'horloge, nous devons, d'abord, convertir chacun de ses chiffres codés au format BCD en son code ASCII approprié. Le codage BCD utilise quatre bits pour coder chaque chiffre décimal. Tandis que, le codage ASCII utilise 8 bits (étendu) ou 7 bits (standard).

On a dit, au début de ce tutoriel (section II-CLe code BCD), que les bits 0-3 de chaque code ASCII d'un chiffre sont égaux à son code BCD. On va utiliser cette propriété pour convertir les codes BCD en des codes ASCII.

Le sous-programme bcd_to_ascii va réaliser cette conversion (voir le code source rtc.s). Pour l'illustrer, nous allons prendre un exemple. Supposons que l'horloge lue soit 23:45:37. Le contenu des registres CX et BX sera :

Image non disponible

Nous allons expliquer comment convertir l'heure (h2h1) du format BCD au format ASCII.

Étape 1 :

Décalage logique (SHR) de 4 bits du contenu du registre AL :

SHR $4, %al : 0010 0011 » 4 = 0000 0010

Étape 2 :

Addition de 48 :

0000 0010 + 00110000 = 110010 = 5010 = ASCII(2)

Ensuite, on charge le contenu de AL dans la mémoire à l'adresse rtc(%si).

Étape 3 :

Maintenant, nous allons convertir h2. Tout d'abord, on charge le contenu du registre CH dans AL. Ensuite, on ajuste le contenu de AL avec l'instruction AAA :

AAA : 0010 0011 ==> 0000 0011

Étape 4 :

Addition de 48 :

0000 0011 + 00110000 = 0011 0011 = 5110 = ASCII(3)

Ensuite, on charge le contenu de AL dans la mémoire à l'adresse rtc(%si).

Les mêmes étapes seront utilisées pour convertir les minutes et les secondes. Utilisez les mêmes commandes (exemple 1) pour assembler, éditer les liens et tester cet exemple.

Exemple 3 : int 0x13, AH = 0x42 : lecture étendue des secteurs.

Dans cet exemple, on va montrer comment utiliser la fonction 0x42 de l'interruption BIOS 0x13 pour lire (en mode réel) des secteurs à partir d'un périphérique de stockage de masse (un flash disk dans notre cas).

Le code source de l'exemple contient deux fichiers :

  1. boot.s : c'est un programme d'amorçage qui sera stocké dans le premier secteur de notre périphérique de stockage. Son rôle est de lire le deuxième secteur du périphérique et de charger le programme stocké dedans (hello.s) en mémoire et, ensuite, lui transférer le contrôle ;
  2. hello.s : le binaire de ce programme sera stocké dans le deuxième secteur de notre périphérique de stockage (juste après le secteur d'amorçage). À son exécution, ce programme va afficher un message à l'écran en utilisant l'interruption BIOS 0x10.

En fait, le BIOS propose une autre fonction de l'interruption 0x13 pour lire des secteurs à partir d'un périphérique de stockage. C'est la fonction classique numéro 0x2 (AH = 0x2).

Cette fonction est très compliquée parce qu'elle utilise l'adressage CHS (Cylinder/Head/Sector) pour localiser les secteurs à lire. De plus, elle limite le nombre de cylindres (pas plus de 1024 cylindres) et le nombre de secteurs (pas plus de 63 secteurs) à lire.

Le nombre de cylindres (d'un disque dur ou d'une disquette) est égal au nombre de pistes (track) par plateau.

Par contre, la fonction 0x42 de l'interruption BIOS est très simple à utiliser et permet de lire n'importe quel secteur du périphérique. Cette fonction traite le périphérique comme un tableau contigu de secteurs (512 octets), où chaque élément (secteur) est identifié uniquement par son numéro (index) ! Cette méthode d'adressage des secteurs de disque dur est souvent nommée Logical Block Addressing, LBA.

Le tableau suivant liste les paramètres qu'on doit spécifier pour l'interruption 0x13 :

AL | 0x42, numéro de la fonction
DL | index du périphérique de stockage (flash disk par exemple)
DS:SI | [segment :offset] pointeur vers le DAP : Disk Address Packet

L'index de périphérique de stockage sera chargé automatiquement par le BIOS, au démarrage, dans le registre DL. D'autre part, le DAP est une structure de données qu'on doit spécifier à l'interruption 0x13. Elle doit contenir exactement les informations suivantes :

Offset | Taille | Description
0x0 | 1 octet | La taille du DAP = 16 octets
0x1 | 1 octet | inutilisé, doit être égal à 0
0x2 - 0x3 | 2 octets | nombre de secteurs à lire
0x4 .. 0x7 | 4 octets | [segment :offset] : un pointeur vers la zone mémoire dans laquelle on va stocker les données lues
0x8 .. 0xf | 8 octets | le numéro du premier secteur qu'on va lire : 0 .. (264 − 1)

Ainsi, la fonction 0x42 peut lire 216 secteurs à la fois (nombre de secteurs à lire) et peut traiter un disque dur (par exemple) de taille 264 ∗ 512 octets ! Et je vous laisse faire le calcul.

En cas d'erreur de lecture, le bit CF du registre EFLAGS sera mis à 1. Une instruction JC (Jump short if carry) peut être utilisée pour traiter l'erreur, en répétant la lecture par exemple.

Le code de notre programme d'amorçage (boot.s) est le suivant :

Sélectionnez
               .code16
 .text
 .global _start
 _start :

               mov     %cs,%ax
               mov     %ax,%ds      # DS = 0 x7c0
               mov     %ax,%es      # ES = 0 x7c0
               mov     %ax,%ss      # SS = 0 x7c0
               mov     $0x100,%sp   # TOS = 256 (512 octets)
               movb    $0x42,%ah    # fonction 0x42 : lecture étendue des secteurs
               movw    $dap,%si     # SI = dap : pointeur vers la structure
               int     $0x13
               ljmp    $0x900,$0x0  # JMP vers [segment:offset] =
                                    # 0x900 x 0x16 + 0x0 = 0x9000

## DAP : Disk Address Packet
dap :          .byte   0x10         # taille de dap = 16 octets
               .byte   0x0          # inutilisé : égale 0
               .word   0x1          # nombre de secteurs à lire
               ## buffer = [ segment:offset] : mémoire vers
               ## laquelle on va transférer le contenu des secteurs lus
               .word   0x0000       # offset
               .word   0x900        # segment
               .quad   0x1          # secteur2 : l'index du premier secteur

               .org    510
               .word   0xaa55

À son exécution, notre programme va lire le deuxième secteur du périphérique de stockage (secteur numéro 1) et charger les données stockées dedans (binaire du programme hello.s) dans la mémoire à l'adresse physique 0x9000 :

segment :offset = 0x0900 :0x0000

adresse physique = 0x0900 * 0x10 + 0x0000 = 0x9000

L'instruction ljmp va transférer le contrôle au programme situé à cette adresse (voir section IV-GSyntaxe des instructions 80386 (IA-32)).

VI. Références

  • Intel 80386 Programmer's Reference Manual
  • le manuel de l'assembleur GNU
  • le manuel du linker ld
  • PC Assembly Language, par Paul A. Carter.

VII. Remerciements

Je remercie Obsidian pour sa relecture technique, ainsi que zoom61 et f-leb pour leur relecture orthographique.

Les sources présentées sur cette page sont libres de droits et vous pouvez les utiliser à votre convenance. Par contre, la page de présentation constitue une œuvre intellectuelle protégée par les droits d'auteur. Copyright © 2014 Issam Abdallah. Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.

Responsable bénévole de la rubrique Assembleur : Alcatîz -


Assembleur Intel avec NASM

Table des matière

  • De l'assembleur
    • I. De l'assembleur
      • I.1. Du langage machine
      • I.2. De l'assembleur par rapport au langage machine
      • I.3. Des fichiers exécutables
      • I.4. Des outils à utiliser
      • I.5. Se jeter à l'eau du haut d'une falaise venteuse
  • Assembleur : suite
    • II. Assembleur : suite
      • II.1. De la directive org
      • II.2. Du mnémonique INT
      • II.3. Des boucles
      • II.4. Des soucis
  • Assembleur : on continue
    • III. Assembleur : on continue
      • III.1. De l'entrée clavier
      • III.2. Des fonctions
      • III.3. De la machine
        • III.3.a. Des drapeaux
        • III.3.b. De la configuration système
      • III.4. De l'affichage des nombres
  • Assembleur : des graphiques !
    • IV. Assembleur : des graphiques !
      • IV.1. De Windows Vista
      • IV.2. Le refacteur sonne toujours une paire de fois (refactoring)
      • IV.3. De VESA
      • IV.4. Du point
      • IV.5. De Bresenham
      • IV.6. Du code
  • Assembleur : au delà de 0x13
    • V. Assembleur : au delà de 0x13
      • V.1. De la limite
      • V.2. Au-delà des limites
      • V.3. D'autres ruses
      • V.4. Du code
  • Assembleur : et si on en avait encore moins ?
    • VI. Assembleur : et si on en avait encore moins ?
      • VI.1. Des machines virtuelles
      • VI.2. Du chargeur de tige de botte
      • VI.3. D'un secteur d'amorçage pour nous
      • VI.4. Passer par noyau planète
      • VI.5. Des 45 tours
      • VI.6. Du massacre de disque
      • VI.7. Du code
  • Assembleur : reconstruction
    • VII. Assembleur : reconstruction
      • VII.1. Du foutoir
      • VII.2. De l'affichage de texte
        • VII.2.a. affiche_chaine
        • VII.2.b. nombre_vers_chaine
      • VII.3. Des modes graphiques
        • VII.3.a. Saut au début, optionnel
        • VII.3.b. P'tit message pour ne pas perdre la main
        • VII.3.c. Demande d'informations sur le VESA BIOS EXTENSION
        • VII.3.d. Traitement de ces informations
        • VII.3.e. Choix du mode graphique
        • VII.3.f. Passage dans le mode choisi
          • VII.3.f.1. Nettoyage de l'écran
        • VII.3.g. Affichage de points
        • VII.3.h. Affichage de droites
        • VII.3.i. Affichage d'un alphabet
      • VII.4. Du code
  • Assembleur : passage en mode protégé
    • VIII. Assembleur : passage en mode protégé
      • VIII.1. Histoire de l'informatique
        • VIII.1.a. L'architecture des premiers Personal Computers
        • VIII.1.b. Le piège d'IBM
        • VIII.1.c. Le mode protégé
      • VIII.2. Problèmes avec le mode protégé
      • VIII.3. Solutions en mode protégé
        • VIII.3.a. Le GDT (Global Descriptor Table)
        • VIII.3.b. Les Descriptors
      • VIII.4. De la pratique
  • Assembleur : mode protégé - Retrouver ses interruptions
    • IX. Assembleur : mode protégé - Retrouver ses interruptions
      • IX.1. Au fait, pourquoi a-t-on inventé les interruptions ?
      • IX.2. Et une interruption, c'est quoi ?
      • IX.3. Comment j'en fais ?
      • IX.4. Du PIC
        • IX.4.a. Du port d'entrée/sortie
        • IX.4.b. ICW1
        • IX.4.c. ICW2
        • IX.4.d. ICW3
        • IX.4.e. ICW4
        • IX.4.f. OCW1
        • IX.4.g. Fin de traitement d'interruption
  • Remerciements
    • X. Remerciements

A la découverte de l'assembleur.

L'auteur

Etienne Sauvage


Table des matières

  • I. De l'assembleur
    • I.1. Du langage machine
    • I.2. De l'assembleur par rapport au langage machine
    • I.3. Des fichiers exécutables
    • I.4. Des outils à utiliser
    • I.5. Se jeter à l'eau du haut d'une falaise venteuse

I. De l'assembleur

Relu par ClaudeLELOUP.

Aujourd'hui, il existe une multitude de langages de programmation aux caractéristiques fort différentes. Des langages-objets, interprétés, portables, etc. se rencontrent quotidiennement. A tel point qu'il faut faire une totale confiance au compilateur pour traduire cela dans la langue de la machine. Car bien évidemment, un ordinateur ne comprend ni le C, ni le C++, encore moins le java, le CamL et autres PHP, Python et consorts. Aussi le curieux peut légitimement se poser cette question : que se passe-t-il vraiment dans le ventre de la machine ? Je joue en ce moment avec le langage de programmation le plus proche de la machine, celui à l'origine de tout : le langage assembleur. Je vous propose de jouer avec moi.

I.1. Du langage machine

Un ordinateur ne comprend rien, à l'origine. Que se passe-t-il à la mise sous tension ? L'ordinateur vient brutalement à la vie. Et sa vie, à l'ordinateur, consiste à lire un livre. A la mise sous tension, il ouvre la première page de son livre et lit le premier mot. Et lire, pour un ordinateur, consiste à se modifier, à changer son état. Or donc, il lit son premier mot, et s'en trouve changé : son mot à lire n'est plus le premier, il devient le deuxième. Il a certaines de ses variables modifiées en fonction du mot lu. Un mot du livre de l'ordinateur, c'est quoi ? C'est un ensemble de fils électriques, sous tension ou pas. Vu qu'on ne s'amuse pas à ajouter ou enlever des fils à tout bout de champ, il s'ensuit que tous ces mots ont le même nombre de lettres, c'est-à-dire de fils. Nous avons ordonné ces fils en leur donnant un numéro. Et on dit que 1 est un fil électrique sous tension, et 0 un fil électrique sans tension. Un mot de l'ordinateur est donc représenté par la suite ordonnée des 0 et des 1 représentant l'état de tension de chaque fil électrique. Et une suite ordonnée de chiffres, ça donne un nombre. 86 est un mot du livre de l'ordinateur. D'un strict point de vue formel, chaque type de processeur doit avoir son propre dictionnaire. 86 n'a a priori aucune chance de signifier la même chose pour un processeur de marque AMD et pour un autre de marque Intel. 86 peut faire une addition de deux variables chez l'AMD, et afficher à l'écran la biographie des rois de France sur l'Intel. Certains se sont alors mis d'accord sur (on a aussi la version : certains ont imposé) un dictionnaire commun. Ce dictionnaire commun, c'est (une partie) de la norme Compatible PC. 86 veut dire (à peu près) la même chose chez tous les ordinateurs compatibles PC. Ca implique quelques bricoles, comme :

  • n'importe qui peut écrire un livre pour n'importe quel ordinateur ;
  • on n'est pas obligé de réécrire toutes les bibliothèques pour tous les ordinateurs ;
  • on ne peut pas mettre n'importe quelle fonction dans un processeur.

Bien. L'informatique a avancé. Chaque nombre est une fonction à peu près partagée par un sous-groupe d'ordinateurs. Extraordinaire. Et si, je dis ça je ne dis rien, mais si, comme ça, histoire que moi je comprenne l'histoire qu'il y a dans le livre de l'ordinateur, on remplaçait, discrètement, tous ces nombres par les noms des fonctions qui leur correspondent ? Dites ? Et de ce jour, le désassembleur était né : traduire en mots plus humains les mots-nombres de l'ordinateur. Et si on faisait l'opération inverse ? Et si j'écrivais les noms des fonctions, et qu'après, on les traduisait en langue ordinateur ? Hou là, très compliqué, c'est de l'analyse de texte, on ne sait pas faire. Ce qu'on peut faire, par contre, c'est associer chaque mot ordinateur avec un tout petit mot d'anglais abrégé. Là, on devrait s'en sortir. C'est de ce modeste objectif que naquit l'assembleur. L'assembleur, c'est le premier langage de programmation différent du langage de la machine.

I.2. De l'assembleur par rapport au langage machine

L'assembleur est donc une traduction du langage machine. Et comme toutes les traductions depuis la tour de Babel, elle n'a aucune raison d'être la même suivant les traducteurs. Et, de fait, l'assembleur diffère selon les programmes de traduction. Ceci implique deux trois petits soucis, notamment le fait que pour que l'ordinateur fasse exactement la même chose, il faut lui demander parfois différemment. C'est ennuyeux. Aussi aime-t-on, comme les chefs d'Etat, garder le même traducteur, qui traduira toujours de la même façon. Ces traducteurs traduisent une langue constituée de mots d'anglais abrégés en une langue constituée de nombres. Ces deux langues sont définies et existent indépendamment du traducteur. Celle que nous voulons traduire, celle constituée de petits mots d'anglais abrégés, est un dictionnaire de mnémoniques. Ces mnémoniques font aussi partie de la norme Compatible PC. La différence entre les traducteurs tient à la façon de les arranger entre eux. Des traducteurs, il en existe quantité. Il faut en choisir un. Affaire de goût. Mais si on souhaite coller au plus près de la machine, et tout produire soi-même parce qu'on fait mi-moyen confiance aux autres compilos, ou parce qu'on veut bien comprendre ce qui se passe dans la machine, il faut en choisir un qui ne fait qu'assembler (i.e. traduire), sans autres fioritures. Personnellement, j'ai choisi NASM. Donc la suite se passera avec NASM. NASM peut, si j'ai tout bien compris, produire des fichiers exécutables pour Windows et Linux.

I.3. Des fichiers exécutables

Justement, qu'est-ce qu'un fichier exécutable ? Sous le prétexte un peu subtil d'organiser tout le schmilblick qu'on peut croiser dans le disque dur d'un ordinateur (données d'Excel, vidéo du petit dernier bavant sur mamie, code de calcul de la bombe atomique), de fourbes ingénieurs en informatique (dont certains étaient même ingénieurs de recherche !) ont décidé, comme ça, qu'on allait avoir des fichiers en lieu et place d'une bouillie d'octets, et que le seul livre que l'ordinateur ouvrirait jamais s'appellerait "le système d'exploitation". Ces gens-là n'ont aucune poésie. Le système d'exploitation sait quels sont les fichiers disponibles sur l'ordinateur, et quels sont ceux qui sont des programmes. Il peut aussi ajouter des chapitres dans son propre livre, c'est-à-dire faire exécuter d'autres programmes à l'ordinateur. Comme tout est fichier (en gros) pour le système d'exploitation, cela signifie que certains fichiers sont des chapitres à ajouter à son livre : les fichiers exécutables. Afin que le système d'exploitation sache quelques bricoles quant à ces chapitres à ajouter, ces fichiers exécutables ont un en-tête donnant des informations aussi futiles que le nombre de pages du chapitre à ajouter, par exemple. Le traducteur ne sait pas écrire cet en-tête, à part pour un seul type de fichiers exécutables : le format COM, disponible sous DOS. Pour les gens qui n'ont pas eu la chance de naître avant Windows 95, DOS est le système d'exploitation sur lequel est construit Windows. Il est toujours accessible, bien que de plus en plus caché dans les tréfonds de l'ordinateur sous Windows. Donc, pour partir du plus simple possible, j'utilise NASM pour générer des fichiers COM.

I.4. Des outils à utiliser

  • NASM, donc.
  • Un éditeur de texte.

    Personnellement, j'ai une tendresse particulière pour celui-ci, qui fait de la coloration syntaxique pour maints langages : Notepad++.

  • Une fenêtre de commande DOS.

    Menu "Démarrer" ou "Windows", "Exécuter un programme", "cmd".

  • DOSBox.

    Il se trouve que les versions les plus récentes de Windows (pardonnez-moi d'être sur un vieux tromblon) interdisent tout un tas de trucs, notamment tout ce qu'on utilise pour commencer l'assembleur. Pour passer outre, exécutez donc les fichiers générés sur DOSBoxLa page de téléchargement de DOSBox.

I.5. Se jeter à l'eau du haut d'une falaise venteuse

Je n'y tiens plus, écrivons notre chapitre du livre de l'ordinateur. Ouvrons notre éditeur préféré, créons un nouveau fichier texte nommé comme on veut (personnellement il s'appelle "Carabistouille.txt") et tapons, avec la force de conviction que donne le bon droit :

Sélectionnez

                    org 0x0100 ; Adresse de début .COM
                    ;Ecriture de la chaîne hello dans la console
                    mov dx, hello
                    mov ah, 0x9
                    int 0x21
                    ret
                    hello: db 'Bonjour papi.', 10, 13, '$'

Une fois sauvegardé, on lance une console, on se met dans le bon répertoire, et on commande :

Sélectionnez
nasm -o [un nom].com [nom complet du fichier texte]

Puis, en étant fou :

Sélectionnez
[un nom].com

Et boum. Ca formate le disque dur. Non, je blague. On a eu "Bonjour papi." affiché dans la console, une ligne vide, puis le retour de l'invite de commande.

Ceci est le plus petit programme qui fasse quelque chose : afficher une chaîne de caractères. Regardez la taille du fichier [un nom].com : 24 octets. On ne peut pas beaucoup plus petit : la chaîne de caractères comporte 16 caractères (Bonjour papi. + caractère 10 + caractère 13 + $), il ne reste que 8 octets de programme à proprement parler.

Maintenant, étudions ce que nous avons écrit ligne par ligne :

Sélectionnez
org 0x0100 ; Adresse de début .COM

Tout de suite, dès le départ, cette ligne n'est pas vraiment une ligne de code : c'est un message envoyé à NASM. org veut dire que tout ce qu'on va écrire n'est pas vraiment le chapitre : on laisse de la place pour l'en-tête du système d'exploitation. Un peu comme si on laissait de la place au début du chapitre pour la préface. 0x0100 est, aussi curieux que cela puisse paraître, un nombre. Il se décompose comme suit :

  • 0x indique qu'il s'agit d'un nombre écrit en hexadécimal ;
  • 0100 est le nombre en hexadécimal.

En notation décimale, ce nombre est 256, soit la taille de l'en-tête. D'ailleurs, on peut tout à fait remplacer "org 0x0100" par "org 256", ça marche pareil. Ce qui nous apprend qu'on peut écrire un nombre en décimal en indiquant simplement le nombre, et l'écrire en hexadécimal en le préfixant par "0x". 0 = zéro, je précise.

Après, on a un point-virgule. Ceci indique à NASM que le reste de la ligne est du commentaire, du gloubi-boulga dont il n'a que faire mais qui peut nous aider, nous autres pauvres êtres biologiques. On notera également que visiblement, le nombre d'espaces entre les choses d'une ligne n'a pas d'importance. Toujours ça de pris.

Sélectionnez
;Ecriture de la chaîne hello dans la console

Commentaire, donc.

Sélectionnez
mov dx, hello

MOV, première instruction processeur. C'est L'instruction processeur, la grande, celle qui est utilisée à tout bout de champ. Elle déplace un nombre d'une case à une autre. Ici, on utilise la syntaxe Intel : destination, source. Si vous êtes passé par de l'assembleur Motorola ou AT&T, c'est l'inverse. On met donc le nombre contenu dans la case "hello" dans la case "dx". Trépidant. Alors, oui, les cases ont des noms. Ou pas. Disons que certaines cases particulières ont des noms, et qu'on peut affecter un nom aux autres, mais sinon, elles ont des numéros. Le nom "hello" correspond en fait à un numéro : le 264. Vous pouvez remplacer "hello" par 264, ça marche tout aussi bien. Pourquoi 264 ? C'est la taille du fichier exécutable moins la longueur de la chaîne de caractères plus la taille de l'en-tête : 24-16+256. Autrement dit, le numéro de l'octet de début de la chaîne une fois le programme chargé en mémoire. DX n'a pas de numéro, parce que c'est une case dans le processeur. Elle a un nom. C'est un registre. Cette instruction met donc dans le registre DX l'adresse du départ de la chaîne de caractères.

Sélectionnez
mov ah, 0x9

On met dans le registre AH la valeur 9, codée en hexadécimal. On peut la coder directement en décimal, ça marche aussi.

Sélectionnez
int 0x21

INT, deuxième instruction processeur. On demande au processeur d'appeler un petit programme stocké ailleurs, qui porte le numéro 0x21, mais bien connu du processeur. Il fait partie d'une catégorie de programmes qu'on appelle interruptions, d'où le mnémonique. C'est un programme du système d'exploitation, donc ça ne marche que sous DOS. C'est SPE-CI-FIQUE à DOS. Ce programme fait des choses différentes selon la valeur stockée dans le registre AH. Si AH vaut 9, alors ce programme affiche la chaîne de caractères dont l'adresse du début est stockée dans DX. Voilà pourquoi on a mis l'adresse de notre chaîne de caractères dans DX, et 9 dans AH. Mais c'est bien gentil d'écrire la chaîne dont on a le début, mais la fin, elle est où ? Hé bien, la fonction 0x9 de l'interruption 0x21 s'arrête quand elle rencontre le caractère "$". C'est pour ça que notre chaîne se termine par un $ qu'on ne voit pas à l'écran.

Sélectionnez
ret

Fin du programme. C'est tout. Fin d'un programme COM.

Sélectionnez
hello: db 'Bonjour papi.', 10, 13, '$'

hello: marque une adresse. Cette case mémoire porte le nom "hello", dorénavant. Utile à NASM, pas au processeur. C'est pour nous éviter de nous balader avec des numéros de cases mémoire un peu partout. "hello" est quand même plus clair que 264, non ? db indique qu'il s'agit de Data Bytes, d'octets de données. Pas d'autres choses : d'octets. Ensuite vient "Bonjour papi.", une virgule pour dire qu'on continue, le caractère numéro 10 (caractère ASCII = octet), le 13, et le $ terminal. Les guillemets servent à demander à NASM de traduire le caractère ASCII en sa valeur numérique. "B" est plus lisible que 66. Comme les caractères 10 et 13 ne sont pas affichables (il s'agit des caractères affichant un saut de ligne), on a indiqué directement leur valeur numérique.

Voilà, je crois qu'on a tout.


  • II. Assembleur : suite
    • II.1. De la directive org
    • II.2. Du mnémonique INT
    • II.3. Des boucles
    • II.4. Des soucis

II. Assembleur : suite

Relu par ClaudeLELOUP.

II.1. De la directive org

La première ligne de notre programme n'est pas, comme nous l'avons vu, une instruction du processeur. C'est un message destiné à NASM. On appelle ça une directive de compilation. Cette directive va demander à NASM d'ajouter un nombre (celui écrit juste après la directive) à toutes les adresses qu'on utilise dans le code. On peut donc, si on veut, le faire à la main et supprimer cette directive. Ca nous donne donc :

Sélectionnez
mov dx, hello + 256

Et on supprime la ligne de la directive. En compilant, on obtient le même résultat.

Néanmoins, cette directive est utile. Si on veut compiler notre programme dans un autre format que COM, ce décalage d'adresses n'aura plus lieu d'être. Plutôt que d'enlever dans toutes les adresses ce décalage, on modifiera uniquement la directive : beaucoup plus facile. Nous garderons donc cette directive, surtout qu'elle servira à introduire des décalages autres que celui du format COM.

II.2. Du mnémonique INT

Le mnémonique INT appelle un sous-programme connu du processeur, appelé une interruption. Ces interruptions sont stockées en mémoire, quelque part. Le où ne m'intéresse pas pour l'instant. Il y en a de différentes sortes, dont deux qui m'intéressent tout de suite.

  • Les interruptions fournies par le DOS.
  • Les interruptions fournies par le BIOS.

L'interruption 0x21, celle que nous avons utilisée, est une interruption DOS. Donc, ce code ne fonctionne pas sur un autre système d'exploitation. Par contre, il existe des interruptions BIOS. Le BIOS est le système d'exploitation fourni avec tous les PC sans exception. BIOS signifie, si je ne m'abuse, Basic Input Output System, système basique d'entrée et de sortie. C'est lui qui démarre l'ordinateur, parce qu'il est directement stocké sur la carte mère, et que quand on démarre l'ordinateur, le malheureux ne sait même pas utiliser son disque dur, sur lequel est stocké le vrai système d'exploitation. Donc, que vous ayez un ordinateur sous Windows, Linux, BeOS ou autre, le BIOS est là au démarrage. Et il est suffisamment standard pour en tirer parti, ce que nous allons faire pas plus tard que maintenant.

Alors, l'interruption du gestionnaire d'affichage vidéo porte le numéro 0x10. Il existe une fonction qui permet d'afficher un seul caractère à l'écran. Hé oui, un seul. Il faut lui remplir plein de registres :

  • le registre AH porte le numéro de la fonction, 0x0A ;
  • le registre AL contient le numéro du caractère à afficher ;
  • le registre BH contient la page d'affichage, mettons 0 pour l'instant ;
  • le registre CX contient le nombre de fois que l'on va afficher le caractère.

Pour l'essayer, tapons dans un fichier :

Sélectionnez

                mov ah, 0x0A
                mov al, 'B'
                xor bx, bx
                mov cx, 1
                int 0x10
                ret

Compilons, exécutons. Un B s'affiche. Mais ce n'est pas fabuleux. On a un B, mais pas de chaîne entière. La source de joie est que l'interruption fonctionne, et que nous nous sommes libérés de l'étreinte du système d'exploitation.

Néanmoins, nous avons là une nouvelle instruction : XOR, mnémonique de la fonction "ou exclusif". On l'utilise à la place de mov bx, 0 parce qu'elle permet une mise à zéro légèrement plus rapide.

II.3. Des boucles

Ce qui serait bien, à présent, ce serait qu'on mette dans le registre AL le premier caractère de notre chaîne "Bonjour papi.", qu'on affiche, puis qu'on mette le deuxième caractère, puis qu'on affiche, puis qu'on mette le troisième et ainsi de suite. Ce qu'il ne faut pas oublier quand on programme, c'est : quand est-ce qu'on s'arrête ?

On ne va pas faire comme le DOS, qui s'arrête quand il rencontre un "$", parce que ça implique qu'on aura quelques difficultés à afficher un $. On va utiliser ce qui se fait dans d'autres langages plus évolués : on va marquer la fin de la chaîne par un caractère non affichable, le caractère justement appelé NULL, de valeur numérique 0.

Voyons le code :

Sélectionnez
org 0x0100 ; Adresse de début .COM

                ;Ecriture de la chaîne hello dans la console
                mov si, hello; met l'adresse de la chaîne à afficher dans le registre SI
                xor bh, bh; RAZ de bh, qui stocke la page d'affichage
                mov ah, 0x03
                int 0x10; appel de l'interruption BIOS qui donne la position du curseur, stockée dans dx
                mov cx, 1; nombre de fois où l'on va afficher un caractère
                affiche_suivant:
                mov al, [si];on met le caractère à afficher dans al
                or al, al;on compare al à zéro pour s'arrêter
                jz fin_affiche_suivant
                mov ah, 0x02;on positionne le curseur
                int 0x10
                mov ah, 0x0A;on affiche le caractère courant cx fois
                int 0x10
                inc si; on passe au caractère suivant
                inc dl; on passe à la colonne suivante pour la position du curseur
                jmp affiche_suivant
                fin_affiche_suivant:
                ret
                hello: db 'Bonjour papi.', 0

Plus dur, non ? Plusieurs choses ont fait leur apparition :

  • des labels en plein milieu du code ;
  • des crochets ;
  • des instructions ;
  • de nouveaux registres.

Je n'ai pas trouvé plus concis comme code. Si on peut, je suis preneur.

Les labels suivis par ":" sont des marqueurs d'adresse. NASM va les remplacer par leur véritable adresse, mais pour écrire dans le code, c'est bien plus clair comme ça.

Les crochets indiquent qu'il ne faut pas prendre la valeur du label, mais la valeur du contenu de la case mémoire dont l'adresse est la valeur du label. Il faut considérer en fait les labels comme des adresses. Quand on met des crochets, on indique qu'il faut prendre ce qu'il y a à cette adresse, et non l'adresse elle-même.

  • L'instruction OR : c'est un "ou" logique. La seule valeur pour laquelle le "ou" logique vaut 0 avec deux fois le même opérande, c'est quand cet opérande est zéro. C'est la façon la plus rapide pour tester si une valeur est égale à zéro : si le résultat de ce "ou" vaut zéro, un bit spécial va être mis à 1 quelque part dans la machine, et pourra être utilisé par l'instruction suivante.
  • L'instruction JZ : Jump if Zero. Si le bit évoqué au-dessus est à 1, le programme va continuer à l'adresse indiquée en paramètre. Sinon, on continue normalement.
  • L'instruction INC : INCrémente. Ajoute 1 au registre indiqué en paramètre.
  • L'instruction JMP : JuMP. Continue le programme à l'adresse indiquée en paramètre. Cette instruction est la base des boucles infinies tant redoutées des programmeurs.

Un processeur dispose d'une tripotée de registres, qui sont les seules cases mémoire qui n'ont pas d'adresse et sur lesquelles le processeur peut faire des opérations. Nous utilisons les registres généraux, qui contiennent des nombres et uniquement des nombres, et, ici, un registre d'adressage qui contient des adresses. Les registres généraux sont A, B, C et D pour l'instant. Ils se décomposent en registres haut et bas, en leur ajoutant H pour haut et L pour low (bas in eunegliche). Le regroupement des parties haute et basse se suffixe par X, pour euh... x (eXtended). On a donc AX composé de AH et AL, BX composé de BH et BL, etc. Le registre SI est un registre d'adressage, utile pour faire des calculs sur les adresses. On ne peut pas utiliser directement l'adresse, puisque "hello" ne contient que l'adresse du premier caractère de la chaîne. Il faut l'adresse de tous les caractères de la chaîne, et donc ajouter 1 à l'adresse courante à chaque fois qu'on affiche un nouveau caractère.

II.4. Des soucis

Vous avez essayé de mettre un retour chariot ? C'est pas beau, non ? Pourquoi ? Parce que le BIOS est bête, et ne fait que ce qu'on lui demande : il affiche un caractère à l'écran. Si ce caractère n'est pas affichable, il fait ce qu'il peut ! Le caractère 13, notamment, est un caractère de mise en forme (dans l'esprit des gens, mais rien n'interdit de le considérer comme affichable). Donc, on peut s'améliorer en déplaçant le curseur d'une ligne vers le bas et en RAZant (de l'informaticien RAZer : effectuer une RAZ, Remise A Zéro) le numéro de colonne quand on rencontre ce caractère.

Ca donne (à insérer après le ret, histoire que ça ne s'exécute pas n'importe comment) :

Sélectionnez

                nouvelle_ligne:
                inc dh; on passe à la ligne suivante
                xor dl, dl; colonne 0
                jmp positionne_curseur
                Et juste après le JZ, on insère :
                cmp al, 13
                je nouvelle_ligne
                positionne_curseur:

Et ce sera tout.

Je vous refais le code en entier, pour la forme :

Sélectionnez

                org 0x0100 ; Adresse de début .COM

                ;Ecriture de la chaîne hello dans la console
                mov si, hello; met l'adresse de la chaîne à afficher dans le registre SI
                xor bh, bh; RAZ de bh, qui stocke la page d'affichage
                mov ah, 0x03
                int 0x10; appel de l'interruption BIOS qui donne la position du curseur, stockée dans dx
                mov cx, 1; nombre de fois où l'on va afficher un caractère
                affiche_suivant:
                mov al, [si];on met le caractère à afficher dans al
                or al, al;on compare al à zéro pour s'arrêter
                jz fin_affiche_suivant
                cmp al, 13
                je nouvelle_ligne
                positionne_curseur:
                mov ah, 0x02;on positionne le curseur
                int 0x10
                mov ah, 0x0A;on affiche le caractère courant cx fois
                int 0x10
                inc si; on passe au caractère suivant
                inc dl; on passe à la colonne suivante pour la position du curseur
                jmp affiche_suivant
                fin_affiche_suivant:
                ret
                nouvelle_ligne:
                inc dh; on passe à la ligne suivante
                xor dl, dl; colonne 0
                jmp positionne_curseur
                hello: db 'Bonjour papi.', 13, 0


  • III. Assembleur : on continue
    • III.1. De l'entrée clavier
    • III.2. Des fonctions
    • III.3. De la machine
      • III.3.a. Des drapeaux
      • III.3.b. De la configuration système
    • III.4. De l'affichage des nombres

III. Assembleur : on continue

Relu par ClaudeLELOUP.

Petit rappel : nous en sommes à avoir un programme qui affiche une chaîne de caractères à l'écran quel que soit le système d'exploitation.

C'est, mine de rien, un excellent point de départ. Mais ça manque de... comment dire... interactivité. Ce qui serait mieux, s'pas, ce serait de dire des choses à l'ordinateur, comme lui nous en dit. Or, l'ordinateur est mal fourni : il est principalement perdu dans ses propres pensées, peu lui chaut le monde extérieur. Et donc, a fortiori, moi. Mon égoïsme est blessé, mon amour-propre traîné dans la boue, et je vais remédier à cela, non de moi de bordel de moi ! Or, autant en possibilités d'actions sur le monde, l'ordinateur est bien loti, autant en possibilités de ressentir le monde, c'est lamentable. Pensez donc, un malheureux clavier et une souris ! L'ordinateur est capable de nous balancer 2000 caractères d'un coup, alors que nous ne pouvons lui donner qu'un seul caractère à la fois ! Scandaleux. Mais on va essayer de faire quelque chose quand même.

III.1. De l'entrée clavier

Monsieur BIOS a un gestionnaire de clavier, de son petit nom INT 0x16, qui a une fonction qui teste si un caractère a été entré au clavier : 0x01. On va donc boucler sur cette fonction tant qu'on n'a pas de caractère à lire. Quand on a quelque chose dans le buffer du clavier, on va le lire avec la fonction 0x00. Histoire de s'en souvenir pour un usage ultérieur, on va le stocker en mémoire. Pour voir ce qu'on tape au clavier, on affiche ce caractère lu. On décale donc le curseur à sa position suivante, et on repart tester s'il n'y aurait pas d'autres caractères à lire. Pour s'arrêter, je décide que la touche "Entrée", caractère 13, sera la marque de la fin de l'entrée clavier. A chaque caractère, on teste alors ce marqueur de fin. S'il est atteint, on passe à la ligne suivante. Puis j'affiche la chaîne entrée. Et pour ce faire, voyons les fonctions.

III.2. Des fonctions

Dans un programme, on a souvent besoin de faire la même chose plusieurs fois. Plutôt que de réécrire l'ensemble du code à chaque fois, nous avons la possibilité de le mettre à un seul endroit, et de l'appeler au besoin : c'est une fonction. Faisons une fonction qui affiche une chaîne de caractères. Elle aura besoin de savoir quelle est la chaîne à afficher : ce sera l'adresse contenue dans SI. J'appelle cette fonction "affiche_chaine" : une fois que l'affichage sera fait, il faut revenir au programme qui appelle cette fonction : instruction RET, qu'on place à la fin du code d'affichage de la chaîne. On décale tout ce code à la fin du programme, juste avant les données, histoire qu'il ne soit pas exécuté n'importe comment. Cette fonction utilise les registres AX, BX, CX et DX. Pour se faciliter le travail, nous allons sauvegarder les valeurs que ces registres avaient avant l'appel à la fonction. Comme ça, l'appel à la fonction est neutre au niveau des registres. Pas la peine de sauvegarder SI, c'est un paramètre : on doit s'attendre à ce qu'il soit modifié par la fonction, ça permettra en outre de savoir combien de caractères ont été écrits. Pour sauvegarder les registres, nous allons utiliser la pile : à nous les messages d'insulte "stack overflow". La pile est un espace mémoire dont l'adresse de fin est fixe, et dont l'adresse de début décroit à mesure qu'on y met des données. La dernière valeur qui y est stockée est donc au début, et sera récupérée en premier. La pile commence par défaut à la fin de l'espace mémoire disponible dans notre programme. Pour y mettre une valeur, c'est l'instruction PUSH, qui met 16 bits dans la pile. Pour récupérer une valeur, POP prend 16 bits de la pile pour les mettre dans un registre.

Pour appeler la fonction, c'est CALL nom_de_la_fonction.

Voici maintenant notre programme :

Sélectionnez

                org 0x0100 ; Adresse de début .COM

                ;Ecriture de la chaîne hello dans la console
                mov si, hello; met l'adresse de la chaîne à afficher dans le registre SI
                call affiche_chaine
                mov si, hello; met l'adresse de la chaîne à lire dans le registre SI
                mov ah, 0x03
                int 0x10; appel de l'interruption BIOS qui donne la position du curseur, stockée dans dx
                mov cx, 1
                attend_clavier:
                mov ah, 0x01;on teste le buffer clavier
                int 0x16
                jz attend_clavier
                ;al contient le code ASCII du caractère
                mov ah, 0x00;on lit le buffer clavier
                int 0x16
                mov [si], al;on met le caractère lu dans si
                inc si
                cmp al, 13
                je fin_attend_clavier
                ;al contient le code ASCII du caractère
                mov ah, 0x0A;on affiche le caractère courant cx fois
                int 0x10
                inc dl; on passe à la colonne suivante pour la position du curseur
                mov ah, 0x02;on positionne le curseur
                int 0x10
                jmp attend_clavier
                fin_attend_clavier:
                inc dh; on passe à la ligne suivante pour la position du curseur
                xor dl, dl
                mov ah, 0x02;on positionne le curseur
                int 0x10
                mov byte [si], 0;on met le caractère terminal dans si
                mov si, hello; met l'adresse de la chaîne à afficher dans le registre SI
                call affiche_chaine
                ret

                affiche_chaine:
                push ax
                push bx
                push cx
                push dx
                xor bh, bh; RAZ de bh, qui stocke la page d'affichage
                mov ah, 0x03
                int 0x10; appel de l'interruption BIOS qui donne la position du curseur, stockée dans dx
                mov cx, 1; nombre de fois où l'on va afficher un caractère
                affiche_suivant:
                mov al, [si];on met le caractère à afficher dans al
                or al, al;on compare al à zéro pour s'arrêter
                jz fin_affiche_suivant
                cmp al, 13
                je nouvelle_ligne
                mov ah, 0x0A;on affiche le caractère courant cx fois
                int 0x10
                inc dl; on passe à la colonne suivante pour la position du curseur
                positionne_curseur:
                inc si; on passe au caractère suivant
                mov ah, 0x02;on positionne le curseur
                int 0x10
                jmp affiche_suivant
                fin_affiche_suivant:
                pop dx
                pop cx
                pop bx
                pop ax
                ret
                nouvelle_ligne:
                inc dh; on passe à la ligne suivante
                xor dl, dl; colonne 0
                jmp positionne_curseur
                ;fin de affiche_chaine

                hello: db 'Bonjour papi.', 13, 0

III.3. De la machine

On arrive à faire des choses, mais ce qui serait bien, maintenant, c'est d'avoir une idée de la machine sur laquelle le programme s'exécute. C'est plus compliqué, parce que toutes ces informations ne vont pas nécessairement être stockées aux mêmes endroits selon, j'imagine, le constructeur, le type de matériel, ce genre de choses.

III.3.a. Des drapeaux

Le processeur dispose d'un registre de drapeaux. Il faut voir un drapeau comme celui d'un arbitre ou d'un commissaire aux courses. Si le drapeau est invisible, il n'y a rien à signaler. Un drapeau levé signifie qu'il y a quelque chose : le vert à pois bleus signifie qu'un martien atterrit, le jaune que le n°13 vient de taper le n°6 d'en face, celui à damier que Senna a rencontré un mur, etc. En informatique, un drapeau est représenté par un bit. Ce bit à 0 signifie qu'il n'y a rien à signaler, le drapeau est invisible. Ce bit à 1 signifie que le drapeau est visible, levé. Non, malheureusement, le processeur n'a pas de drapeau pour les aliens. Voici des exemples de drapeaux dont le processeur dispose :

  • CF, Carry Flag, drapeau de retenue : cela symbolise la retenue, comme à l'école ;
  • ZF, Zero Flag, drapeau de zéro : vous venez de calculer la tête à toto ;
  • PF, Parity Flag, drapeau de parité : votre résultat est pair. Le virus Parity Boot changeait la valeur de ce drapeau, le canaillou ;
  • SF, Sign Flag, drapeau de signe : votre résultat est négatif ;
  • DF, Direction Flag, drapeau de direction : vous allez dans le sens des adresses décroissantes, c'est le voyant de recul de votre voiture ;
  • OF, Overflow Flag, drapeau de dépassement de capacité : votre résultat ne tient plus dans le type que vous avez choisi.

Ces drapeaux sont utilisés par les instructions de saut conditionnel. JZ, par exemple, signifie Jump if Zero et saute (à l'adresse donnée en paramètre) si le drapeau ZF est levé. Certains appels de fonction changent l'état des drapeaux selon une logique qui leur est propre, et les drapeaux perdent ainsi leur signification première, un peu comme si l'arbitre sortait, au lieu du carton rouge attendu, un ananas violet. Ca met le bazar, c'est drôle, les informaticiens adorent. Oui, nous aussi on jouera avec. Des drapeaux, le processeur en a tout un registre, ce qui fait, au maximum ...

...

...

...

16 ! Nos registres font 16 bits, ce qui fait 16 drapeaux possibles au maximum. Et le registre des drapeaux s'appelle, je vous le donne en mille : FLAGS. Quelque chose me dit que le processeur est anglo-saxon.

III.3.b. De la configuration système

Alors, comme point de départ, j'ai trouvé l'interruption 0x11, qui remplit AX avec des flags et des nombres. Appelons donc 0x11 et analysons AX. On utilise l'instruction TEST, qui lève des drapeaux en fonction du résultat du ET logique entre les opérateurs. TEST ne change pas les valeurs des opérandes, ce qui est pratique, ça évite d'appeler l'interruption à chaque fois qu'on teste une partie de la valeur de retour. On teste avec un nombre binaire, préfixé 0b : ça permet de voir facilement le bit qui nous intéresse. Par exemple, si on teste le premier bit, on teste avec 0b0001 : le résultat sera zéro, soit ZF à 1, ou bien un, soit ZF à 0. Une instruction de saut conditionnel fera le reste, et AX sera prêt à être testé avec le deuxième bit, soit 0b0010.

Les troisième et quatrième bits commencent à être tordus : il s'agit de la mémoire disponible, comptée en nombre de blocs de 16 kilooctets. Mais comme un ordinateur sans mémoire vive ne sert à rien (merci monsieur Turing), le premier bloc n'est pas compté : il y a au moins 16 ko (kilooctets) de mémoire dans un ordinateur. Il faut donc ajouter 1 au nombre de pages de 16 ko. Les valeurs disponibles sur 2 bits sont : 0, 1, 2 et 3. Notre nombre de pages est alors de 1, 2, 3 ou 4, soit 16, 32, 48 ou 64 ko.

Il n'y a pas de test à faire, il faut récupérer le nombre. Pour le récupérer, il faut transformer notre retour afin qu'il soit notre nombre de pages, i.e. décaler ses bits de deux bits vers la droite (après suppression des bits qui ne nous intéressent pas par un AND). Il s'agit de l'instruction SHR, pour SHift Right (décalage à droite). Après, on incrémente pour prendre en compte la page gratis, et il faudrait multiplier par 16 pour avoir le nombre de ko disponibles. Une multiplication, c'est bien. Mais mieux, c'est le décalage de bits. Pour la faire courte, un décalage de 1 bit à droite correspond à une division par 2, tandis qu'un décalage de 1 bit à gauche fait une multiplication par 2. Pour multiplier par 16, il faut multiplier par 2 quatre fois, donc décaler de 4 bits vers la gauche, soit SHL ax, 4.

Ayant récupéré notre quantité de mémoire disponible, nous souhaiterions l'afficher. Or, il s'agit d'un nombre, pas d'une chaîne de caractères. "R" ne nous apprendrait rien. Il faut transformer notre nombre en chaîne de caractères.

III.4. De l'affichage des nombres

Faisons une fonction de transformation de nombre en chaîne de caractères. AX contient le nombre à transformer, et SI l'adresse du début de la chaîne. On utilise l'algorithme appelé "algorithme des divisions successives". On va diviser notre nombre par 10 jusqu'à ce que le quotient soit 0, et stocker chaque reste comme étant un chiffre à afficher. Les chiffres ASCII ayant le bon goût d'être dans l'ordre, il suffit d'ajouter "0" à un chiffre pour avoir son caractère.

Alors, à la main : prenons 32. Divisons 32 par 10 : quotient 3, reste 2. 2 est le chiffre à afficher. Stockons 2 + "0". Divisons 3 par 10 : reste 3, quotient 0. Stockons 3 + "0". Le quotient est 0, fin de l'algorithme. La chaîne est dans l'ordre inverse. Si on l'a stockée dans la pile, en dépilant, on sera dans le bon ordre. Ne reste plus qu'à ajouter le zéro terminal et à l'afficher.

Une division se fait par l'instruction DIV suivie du diviseur, qui doit être dans un registre. DIV divise AX par le diviseur, et stocke le résultat dans AH pour le reste et AL pour le quotient. Note : avec ça, on ne pourra pas traiter de nombre plus grand que 2550.

Le code complet :

Sélectionnez

                org 0x0100 ; Adresse de début .COM

                ;Ecriture de la chaîne hello dans la console
                mov si, hello; met l'adresse de la chaîne à afficher dans le registre SI
                call affiche_chaine
                mov si, hello; met l'adresse de la chaîne à lire dans le registre SI
                mov ah, 0x03
                int 0x10; appel de l'interruption BIOS qui donne la position du curseur, stockée dans dx
                mov cx, 1
                attend_clavier:
                mov ah, 0x01;on teste le buffer clavier
                int 0x16
                jz attend_clavier
                ;al contient le code ASCII du caractère
                mov ah, 0x00;on lit le buffer clavier
                int 0x16
                mov [si], al;on met le caractère lu dans si
                inc si
                cmp al, 13
                je fin_attend_clavier
                ;al contient le code ASCII du caractère
                mov ah, 0x0A;on affiche le caractère courant cx fois
                int 0x10
                inc dl; on passe à la colonne suivante pour la position du curseur
                mov ah, 0x02;on positionne le curseur
                int 0x10
                jmp attend_clavier
                fin_attend_clavier:
                inc dh; on passe à la ligne suivante pour la position du curseur
                xor dl, dl
                mov ah, 0x02;on positionne le curseur
                int 0x10
                mov byte [si], 0;on met le caractère terminal dans si
                mov si, hello; met l'adresse de la chaîne à afficher dans le registre SI
                call affiche_chaine

                int 0x11
                test ax, 0b0001
                jnz lecteurs_disquette
                mov si, pas_disquette
                call affiche_chaine
                test_coprocesseur:
                test ax, 0b0010
                jnz coprocesseur_present
                mov si, pas_coprocesseur
                call affiche_chaine
                test_memoire:
                and ax, 0b1100
                shr ax, 2
                inc ax; une zone mémoire est donnée gratis.
                shl ax, 4; les zones mémoires sont comptées par paquets de 16 ko
                mov si, hello
                call nombre_vers_chaine
                mov si, hello
                call affiche_chaine
                mov si, memoire_dispo
                call affiche_chaine
                ret

                lecteurs_disquette:
                mov si, disquettes
                call affiche_chaine
                jmp test_coprocesseur

                coprocesseur_present:
                mov si, coprocesseur
                call affiche_chaine
                jmp test_memoire

                nombre_vers_chaine:
                push bx
                push cx
                push dx
                mov bl, 10
                mov cx, 1
                xor dh, dh
                stocke_digit:
                div bl
                mov dl, ah
                push dx ;sauve le reste dans la pile
                inc cx
                xor ah, ah
                or al, al
                jne stocke_digit

                ;Affichage du chiffre
                boucle_digit:
                loop affiche_digit
                mov byte [si], 0
                pop dx
                pop cx
                pop bx
                ret

                affiche_digit:
                pop ax
                add ax, '0'
                mov [si], al
                inc si
                jmp boucle_digit
                ;fin nombre_vers_chaine

                affiche_chaine:
                push ax
                push bx
                push cx
                push dx
                xor bh, bh; RAZ de bh, qui stocke la page d'affichage
                mov ah, 0x03
                int 0x10; appel de l'interruption BIOS qui donne la position du curseur, stockée dans dx
                mov cx, 1; nombre de fois où l'on va afficher un caractère
                affiche_suivant:
                mov al, [si];on met le caractère à afficher dans al
                or al, al;on compare al à zéro pour s'arrêter
                jz fin_affiche_suivant
                cmp al, 13
                je nouvelle_ligne
                mov ah, 0x0A;on affiche le caractère courant cx fois
                int 0x10
                inc dl; on passe à la colonne suivante pour la position du curseur
                positionne_curseur:
                inc si; on passe au caractère suivant
                mov ah, 0x02;on positionne le curseur
                int 0x10
                jmp affiche_suivant
                fin_affiche_suivant:
                pop dx
                pop cx
                pop bx
                pop ax
                ret
                nouvelle_ligne:
                inc dh; on passe à la ligne suivante
                xor dl, dl; colonne 0
                jmp positionne_curseur
                ;fin de affiche_chaine

                disquettes: db 'Lecteur(s) de disquette', 13, 0
                pas_disquette: db 'Pas de lecteur de disquette', 13, 0
                coprocesseur: db 'Coprocesseur arithmétique', 13, 0
                pas_coprocesseur: db 'Pas de coprocesseur', 13, 0
                memoire_dispo: db ' ko.', 13, 0
                hello: db 'Bonjour papi.', 13, 0


  • IV. Assembleur : des graphiques !
    • IV.1. De Windows Vista
    • IV.2. Le refacteur sonne toujours une paire de fois (refactoring)
    • IV.3. De VESA
    • IV.4. Du point
    • IV.5. De Bresenham
    • IV.6. Du code

IV. Assembleur : des graphiques !

Relu par ClaudeLELOUP.

Aux dernières nouvelles, nous en étions à lire des choses au clavier et à regarder un peu ce qu'il y a dans la machine. C'est bien. On peut mieux. On peut mieux comment ? Allez, si on mettait à profit l'extraordinaire capacité de l'ordinateur à faire de l'affichage ?

IV.1. De Windows Vista

Je ne veux pas être mauvaise langue, mais Windows Vista, entre nous soit dit, ce n'est pas fabuleux. On ne pourra plus exécuter notre programme sous Vista, parce que Môssieur Vista a décidé que non, c'était pas bien d'accéder au BIOS, surtout pour faire de l'affichage. Si bien que quand on va appeler une certaine fonction du gestionnaire d'affichage du BIOS (la fonction du pilote VESA), on va se faire jeter comme des malpropres. Ce n'est pas chouette de sa part, alors on va ruser : DOSBOX est un émulateur DOS qui fonctionne sous Vista.

Doit-on considérer qu'on est mauvais si on se fait renvoyer dans nos 6 mètres par Vista ? Je ne crois pas : VESA est un standard, je ne vois pas pourquoi on n'aurait pas le droit de l'utiliser.

Pour nous enquiquiner encore plus, NASM ne fonctionne pas avec DOSBOX. Donc, deux fenêtres : une en cmd normal, et une de DOSBOX. Je VEUX y arriver, et c'est pas Kro$oft qui va me l'interdire !

IV.2. Le refacteur sonne toujours une paire de fois (refactoring)

Quand le code évolue, on change des trucs et des machins. On refait d'autres trucs. "Cent fois sur le métier remets ton ouvrage", disait Penelope Cruz, à moins que je n'aie pas tout saisi (note du correcteur : ça doit remuer dans les allées du Père Lachaise. Pauvre Boileau !). J'ai amélioré la fonction d'écriture de texte, histoire qu'on n'écrive pas n'importe où : quand on a plus de 80 caractères à afficher, elle faisait un peu n'importe quoi. Mais pas que. Une nouvelle instruction a fait son apparition : LODSB, comme LOaD String Byte. C'est l'équivalent de MOV [DS:SI], AL INC SI. Et pour être cohérent, plus facile à utiliser, l'adresse de la chaîne à afficher se passe maintenant dans le registre SI (Source Index) pour son offset, et DS (Data Segment) pour son segment.

J'ai mis la lecture au clavier dans une fonction, ça aura l'avantage de pouvoir être appelé n'importe quand, et donc de servir d'arrêt dans l'exécution du programme, ah là là, je suis d'une fourberie sans nom. Là aussi, une nouvelle fonction a fait son apparition : STOSB, comme STOre String Byte. Exactement l'inverse de LOSB, mais avec des registres différents : DI (Destination Index) comme offset d'adresse et ES (Extra Segment) comme segment d'adresse. Instructions équivalentes : MOV AL, [ES:DI] INC DI. Donc, on passe l'adresse de la chaîne de destination dans ces registres.

La fonction d'affichage de nombres a subi elle aussi une cure de jouvence : on peut, pensez donc, afficher des nombres jusqu'à 65535 ! Elle prend d'autres paramètres, comme BL, dont le bit de poids faible est mis à un si on veut terminer la chaîne de caractères à la fin, et BH, qui donne le nombre minimal de caractères que doit contenir la chaîne écrite. Ah oui. J'oubliais. Cette fonction transforme AX en chaîne de caractères. Elle ne l'affiche pas.

Il paraît que refaire des choses, comme cela, s'appelle du refactoring. C'est un bien vilain mot qui écorche l'oreille fine du franchouillard : recodage serait mieux, de mon point de vue.

IV.3. De VESA

Au commencement était la carte perforée. Puis vint la télévision. Ou inversement. Bref, les ordinateurs furent munis d'un écran. Monochrome. Puis on sut écrire autre chose que du texte. Puis apparut le niveau de gris, puis la couleur. Puis tout le monde fit n'importe quoi. IBM sortit alors la spécification VGA. On progressa encore. Ce fut pire. Re standard, et VESA.

VESA est, succinctement, le pilote des cartes graphiques au-delà de VGA. Pour savoir s'il est disponible, une interruption : 0x10 (puisque c'est du gestionnaire vidéo), une fonction : 0x4F, et une tripotée de sous-fonctions. L'idée est d'appeler la sous-fonction 0x00 qui va nous dire tellement de choses que bien des tortionnaires envieraient la place du développeur.

A cette sous-fonction, il faut lui donner un espace mémoire pour qu'elle puisse y mettre toutes ces choses qu'elle sait. Elle va les mettre à l'adresse contenue dans le registre DI (comme Destination Index), et elle a besoin de 256 octets. Nous allons lui donner entière satisfaction, puisque nous allons lui donner notre adresse fourre-tout, à savoir le label hello. On dispose d'au moins 16 ko de mémoire, donc on a de la place en bout de programme pour stocker 256 octets, on ne s'occupe de rien en ce moment.

Comme nous sommes des fous parfaitement au fait du fonctionnement d'un microprocesseur, on colle directement les paramètres dans AX en une seule instruction, ah oui madame, je suis un malade des cycles d'horloge, c'est précieux ces choses-là.

Voici la structure de ce qui nous est renvoyé :

Offset | Taille | Description
0x00 | 4 octets (Double Mot) | Signature, doit contenir la chaîne de caractères "VESA".
0x04 | 2 octets (Mot) | Numéro de version du VESA. AH : numéro majeur. AL : numéro mineur.
0x06 | 4 octets (Double Mot) | Pointeur sur le nom de l'OEM terminé par un caractère NULL.
0x0A | 4 octets (Double Mot) | Ca, je n'ai pas compris.
0x0E | 4 octets (Double Mot) | Pointeur sur une liste de modes vidéo du VESA supportés par l'OEM. Se termine par le code 0xFFFF. Chaque mode est stocké comme un mot.
0x11 | 238 octets | Ca, je n'ai pas compris.

Nous allons lire et afficher tous les mots (mot veut dire nombre de 16 bits ici) tant qu'on ne lit pas le nombre magique 0xFFFF. Bon, d'accord, c'est beaucoup d'efforts pour pas grand-chose, puisque après, on se mettra dans le mode 0x13. Mais on sera prêts pour la suite.

IV.4. Du point

Ensuite, on est parti pour l'affichage d'un point. Ca consiste en l'écriture dans la mémoire vidéo d'une couleur. Une couleur, en mode 0x13, c'est un octet. Les coordonnées en abscisse et ordonnée du point à afficher doivent être transformées en coordonnée linéaire, la mémoire étant linéaire. Pour ce faire, la formule est : ordonnée * nombre de points sur la ligne + abscisse. Subtilité : plutôt que de faire une multiplication par 320, on travaille par deux décalages de bits (multiplication par puissances de 2) additionnés. On écrit à cette coordonnée linéaire la valeur de la couleur, et c'est fait.

IV.5. De Bresenham

Maintenant, tracer des lignes. Des segments de droite, pour être exact. Il nous faut un algorithme, c'est-à-dire la marche à suivre pour parvenir à nos fins. Wikipédia nous en fournit un, qui est le top des algorithmes de tracé de segment : l'algorithme de Bresenham

Cet algorithme, il faut le traduire en assembleur. Ma traduction n'est certainement pas fabuleuse, mais aux dernières nouvelles, elle fonctionne.

IV.6. Du code

Le code de ce chapitre se trouve ici : carabistouille.asm. Parce que bon, ça commence à faire de la ligne de code.


  • V. Assembleur : au delà de 0x13
    • V.1. De la limite
    • V.2. Au-delà des limites
    • V.3. D'autres ruses
    • V.4. Du code

V. Assembleur : au delà de 0x13

Relu par ClaudeLELOUP.

Dessiner, c'est bien. Après tout, on commence à dessiner avant d'écrire, nous autres mortels. Continuons donc le dessin. Nous savons dessiner des droites en mode VGA, c'est à dire en 320 par 200. Mais les ordinateurs peuvent faire beaucoup mieux en résolution : du 1280 par 1024 ne devrait pas faire peur à un PC du XXIe siècle. Essayons.

V.1. De la limite

Notre processeur est utilisé en 16 bits. Sans subtilité, nous pouvons manipuler des nombres qui vont gaillardement jusque 65535. Mais pas plus. Pareil pour les adresses. On peut adresser 65536 octets en mémoire. Or, un octet étant une couleur (en mode 256 couleurs), on peut donc écrire 65536 points à l'écran en une seule passe. Quand on se rend compte que 320 par 200, ça fait 64000, on se dit qu'un bête 800 par 600 va nécessiter de la ruse.

V.2. Au-delà des limites

M. VESA, que nous avons déjà rencontré au chapitre précédent, y a pensé. Et il s'est dit, comme ça, que nous écrirons l'écran toujours aux mêmes adresses, donc, pas plus de 65536 octets d'un coup. L'idée est donc de couper l'écran en morceaux de 64 ko, et de demander à la carte graphique de changer de morceau. Comme ça, nous, on ne fait des choses que sur un seul morceau de 64 ko, et on dit à la carte graphique : ça c'est le morceau 0, ça c'est le morceau 6, etc. Pour changer de morceau, on appelle l'interruption 0x10 (le gestionnaire d'affichage), la fonction 0x4F (VESA), sous-fonction 0x05, avec BH à zéro et le numéro du morceau dans DX.

Au fait, pourquoi le numéro du morceau dans DX ?

Tiens, oui, pourquoi ?

Vous allez voir, c'est bien fait.

Si si, c'est intelligent.

Pourquoi dans DX ?

Aucune idée ?

Comment on calcule le numéro du morceau ?

Vi. Tout à fait. On utilise la formule : Y * largeur de la ligne + X, avec (X, Y) coordonnées du point à afficher. Or Y * largeur de la ligne va donner un résultat supérieur à 65536. Et l'opération de multiplication dans le processeur va mettre dans DX, quand ça dépasse 65535, le nombre de fois qu'il y a 65536 dans notre résultat. Et ce nombre de fois qu'il y a 65536, c'est précisément notre numéro de morceau ! Il n'y a donc qu'à appeler la sous-fonction 0x05 avec le résultat de la multiplication, et le tour est joué. Elle est pas belle, la vie ?

Bon.

En fait, c'est pas vrai.

On peut changer de morceau d'écran en cours de ligne. Sinon, ce n'est pas drôle. Si on a une retenue à l'ajout de l'abscisse, c'est qu'on change de morceau en cours de route. ADC (ADd with Carry) sur DX nous mettra alors sur le bon morceau d'écran.

V.3. D'autres ruses

Pour demander une nouvelle résolution, on passe son numéro. Il y a pourtant une petite subtilité : si on demande le mode brutalement, VESA va mettre le contenu de l'écran à 0. Si on n'en a pas besoin, il faut ajouter 0x8000 au numéro du mode : VESA ne fera pas de nettoyage.

D'autre part, on change rarement de morceau d'écran. En stockant dans une variable le numéro du morceau courant, on pourra ne demander le changement que lorsque le morceau d'écran du point à afficher est différent de celui stocké précédemment.

Plus drôle encore : il arrive que la longueur de la ligne soit, en mémoire, plus grande que celle affichée. C'est notamment le cas lorsque le nombre de points à l'écran est différent de multiples de 65536. En gros, ça veut dire qu'il y a des points non affichés. Ces points non affichés sont situés en bout de ligne. Pour faire nos calculs, nous n'allons pas utiliser le nombre de points par ligne affichés, mais le nombre d'octets par ligne.

V.4. Du code

Le code de ce chapitre se trouve ici : VESA.asm Il y a des morceaux de code commentés, et des bidules que nous verrons plus tard.


  • VI. Assembleur : et si on en avait encore moins ?
    • VI.1. Des machines virtuelles
    • VI.2. Du chargeur de tige de botte
    • VI.3. D'un secteur d'amorçage pour nous
    • VI.4. Passer par noyau planète
    • VI.5. Des 45 tours
    • VI.6. Du massacre de disque
    • VI.7. Du code

VI. Assembleur : et si on en avait encore moins ?

Relu par ClaudeLELOUP.

On a écrit à l'écran, dessiné, lu l'entrée clavier. Nous l'avons fait sans utiliser les facilités fournies par le système d'exploitation. Mais nous utilisons toujours le système d'exploitation, parce que nous lui demandons l'exécution de notre programme. Il nous modifie notre fichier, c'est le fameux org 256 que nous devons écrire pour que nos adresses restent valides dans notre programme. Et bien, ça aussi, je n'en veux plus.

VI.1. Des machines virtuelles

Pour que le système d'exploitation n'existe plus, un bon moyen est le "format c:". C'est très bien, mais très gênant, puisque finalement, ça supprime tout, et donc aussi notre éditeur de texte pour écrire notre programme. Et sans éditeur, nous ne pourrons pas écrire quoi que ce soit, et donc il sera impossible de refaire fonctionner l'ordinateur. C'est une mauvaise idée. Mais il se trouve que beaucoup de gens ont besoin d'un ordinateur avec rien dessus. Et à l'ère de l'internet, si beaucoup de gens ont besoin de quelque chose, alors quelqu'un l'a fait. Ca s'appelle une machine virtuelle, c'est un logiciel qui simule un ordinateur sur votre ordinateur. Oui, le concept est étrange, mais c'est bien pratique. Il en existe plusieurs, prenez celui que vous voulez tant que l'on peut y configurer un PC sans système d'exploitation. Personnellement, j'ai tenté ces deux-là :

  • VirtualBox
  • Bochs

VirtualBox est plus sympa à utiliser que Bochs, mais Bochs a aussi ses avantages, c'est même pour ça que les deux existent.

Ce qu'il nous faut, nous, c'est un PC avec un lecteur de disquettes avec une disquette dedans. La disquette sera de préférence un fichier ".img" mais c'est au choix. L'idée est d'avoir un ordinateur, même faux, sur lequel on va pouvoir jouer autant qu'on veut sans pour autant tout casser.

VI.2. Du chargeur de tige de botte

Au démarrage, un ordinateur a un gros problème : il doit charger en mémoire un programme qui lui permettra de lancer d'autres programmes, qui éventuellement, lanceront d'autres programmes, etc. Mais le premier programme, comment se charge-t-il en mémoire ?

C'est un peu le problème de la poule et de l'oeuf...

L'ordinateur va utiliser la technique du baron de Münchausen : alors que le baron allait se noyer, il appelle à l'aide. Comme personne ne répond, le baron se trouve contraint, pour se sauver, de se tirer lui-même de l'eau par ses tiges de botte. En anglais, ça se dit "bootstrap". En français, on est moins poétique et on appelle le "bootstrap loader" : le chargeur de secteur d'amorçage.

Le BIOS d'un PC va, au démarrage, chercher, sur certains périphériques, un bloc de 512 octets qui se termine par la séquence 0x55AA. 0x55AA est un nombre magique : ça n'a aucune raison particulière d'être ce nombre-là plutôt qu'un autre, simplement, il en faut un, alors, on en sort un du chapeau. Et sortir du chapeau, c'est magique.

Ce bloc de 512 octets, il va le mettre à l'adresse 0x07C0:0000, et lui donner la main. C'est comme ça. C'est 0x07C0:0000 et puis c'est marre.

Ce bloc de 512 octets, nous l'allons écrire.

VI.3. D'un secteur d'amorçage pour nous

Bien sûr, on peut utiliser des secteurs d'amorçage déjà faits. Mais mon but est de partir du plus bas. Il me faut donc le mien.

Alors, hardi, compagnons, portons haut la flamberge et écrivons :

Sélectionnez

                org 0x0000 ; On n'a pas de décalage d'adresse

                    jmp 0x07C0:debut     ; On est chargé dans le segment 0x07C0
                debut:
                    ; Met les segments utiles au segment de code courant
                    mov ax, cs
                    mov ds, ax
                    call detect_cpu

                initialise_disque: ; Initialise le lecteur de disque
                    xor ax, ax

Ici, une parenthèse : on est censé donner à l'interruption 0x13 l'identifiant du disque sur lequel on est chargé via le registre DL. Or, il se trouve que le BIOS, décidément conçu par des gens qui ne veulent pas s'embêter, nous donne le lecteur sur lequel il nous a trouvé dans le registre DL. Comme notre programme sera sur le même disque, on n'a rien à changer. Fin de la parenthèse.

Sélectionnez

                    int 0x13
                    jc initialise_disque; En cas d'erreur on recommence (sinon, de toute façon, on ne peut rien faire)

                lire:

De nouveau une parenthèse : on va charger un autre programme que le secteur d'amorçage en mémoire. Où allons-nous le mettre ? Pour l'instant, où on veut, on est l'chef, après tout. 0x1000:0000 n'a pas l'air trop mal en première approximation. Et on va charger 5 secteurs, parce que notre programme fera moins de 2,5 ko. Ah oui, c'est comme ça.

Sélectionnez

                    mov ax, 0x1000 ; ES:BX = 1000:0000
                    xor bx, bx
                    mov es, ax
                    mov ah, 2 ; Fonction 0x02 : chargement mémoire
                    mov al, 6 ; On s'arrête au secteur n° 6
                    xor ch, ch ; Premier cylindre (n° 0)
                    mov cl, 2 ; Premier secteur (porte le n° 2, le n° 1, on est dedans, et le n° 0 n'existe pas)
                    ; Ca fait donc 5 secteurs
                    xor dh, dh ; Tête de lecture n° 0
                    ; Toujours pas d'identifiant de disque, c'est toujours le même.
                    int 0x13 ; Lit !
                    jc lire ; En cas d'erreur, on recommence
                    mov si, sautNoyau ; Un petit message pour rassurer les troupes.
                    call affiche_chaine
                    jmp 0x1000:0000 ; Et on donne la main au programme que nous venons de charger

Alors, pour la fine bouche : on va faire de l'affichage et du test. D'abord, on va tester le processeur, car depuis qu'on a commencé, j'ai appris des trucs : pour savoir si on a à faire à un 8086, 80286 ou 80386, on teste certains bits du registre de drapeaux.

Sélectionnez

                detect_cpu:
                    mov si, processormsg ; Dit à l' utilisateur ce qu'on est en train de faire
                    call affiche_chaine

                    mov si, proc8086 ; De base, on considère qu'il s'agit d'un 8086
                    pushf ; sauvegarde les valeurs originales des drapeaux

                    ; teste si un 8088/8086 est présent (les bits 12-15 sont à 1)
                    xor ah, ah ; Met les bits 12-15 à 0
                    call test_drapeaux
                    cmp ah, 0xF0
                    je finDetectCpu ; 8088/8086 détecté

                    mov si, proc286 ; On considère qu'il s'agit d'un 286
                    ; teste si un 286 est présent (les bits 12-15 sont effacés)
                    mov ah, 0xF0 ; Met les bits 12-15 à 1
                    call test_drapeaux
                    jz finDetectCpu ; 286 détecté

                    mov si, proc386 ; aucun 8088/8086 ou 286, donc c'est un 386 ou plus
                finDetectCpu:
                    popf ; restaure les valeurs originales des flags
                    call affiche_chaine
                    ret

                test_drapeaux:
                    push ax ; copie AX dans la pile
                    popf ; Récupère AX en tant que registre de drapeaux. Les bits 12-15 sont initialisés pour le test
                    pushf ; Remet le registre de drapeaux sur la pile
                    pop ax ; Les drapeaux sont mis dans AX pour analyse
                    and ah, 0xF0 ; Ne garde que les bits 12-15
                    ret

Et maintenant, la culte routine d'affichage en mode texte

Sélectionnez

                affiche_chaine:
                    push ax
                    push bx
                    push cx
                    push dx
                    xor bh, bh; RAZ de bh, qui stocke la page d'affichage
                    mov ah, 0x03
                    int 0x10; appel de l'interruption BIOS qui donne la position du curseur, stockée dans dx
                    mov cx, 1; nombre de fois où l'on va afficher un caractère
                affiche_suivant:
                    lodsb
                    or al, al;on compare al à zéro pour s'arrêter
                    jz fin_affiche_suivant
                    cmp al, 13
                    je nouvelle_ligne
                    mov ah, 0x0A;on affiche le caractère courant cx fois
                    int 0x10
                    inc dl; on passe à la colonne suivante pour la position du curseur
                    cmp dl, 80
                    jne positionne_curseur
                nouvelle_ligne:
                    inc dh; on passe à la ligne suivante
                    xor dl, dl; colonne 0
                positionne_curseur:
                    mov ah, 0x02;on positionne le curseur
                    int 0x10
                    jmp affiche_suivant
                fin_affiche_suivant:
                    pop dx
                    pop cx
                    pop bx
                    pop ax
                    ret
                ;fin de affiche_chaine

                proc8086: db '8086', 13, 0
                proc286: db '286', 13, 0
                proc386: db '386', 13, 0
                processormsg: db 'Test du processeur : ', 0
                sautNoyau: db 'Saut au noyau', 13, 0

Petit souci : notre secteur d'amorçage doit se terminer par 0x55AA, qui est un mot et qui doit donc être à la fin des 512 octets. Pour le mettre à la fin, nous allons simplement remplir les octets libres jusque 510 avec des 0. Cela fait donc 510 octets, moins l'adresse du dernier octet de code, moins l'adresse du premier octet de la section (qui est égale à 0, mais là n'est pas le problème). NASM fournit "$" comme étant l'adresse du début de la ligne de code courante, et "$$" comme l'adresse de la première instruction de la section. NASM fournit "times", qui répète ce qui le suit un certain nombre de fois. On utilise times 510 - taille de notre code fois pour écrire 0, et le tour est joué.

Sélectionnez

                times 510-($-$$) db 0
                dw 0xAA55 ; Le nombre magique écrit à l'envers parce que M. Intel est grosboutiste, ce qui signifie qu'il inverse les octets d'un mot.

Nous avons maintenant notre secteur d'amorçage, à compiler avec : nasm -o amorce.com amorce.asm

Et on ne le lance pas bêtement. On attend d'avoir fait la suite.

VI.4. Passer par noyau planète

La suite est un autre programme : nouveau fichier, page blanche... Noooon... pas page blanche, on va être un peu malin, on va partir du chapitre précédent. Simplement, on va, au départ, initialiser les segments utilisés par notre programme. Comme tout est dans un seul segment, on va les initialiser avec la valeur de CS, le segment de code, qui est bon, puisqu'il a été initialisé par le secteur d'amorçage. Et pour ne pas s'emberlificoter, on va utiliser un autre segment pour la pile. Au pif, encore une fois.

Ensuite, techniquement, nous développons ce qu'il est convenu d'appeler un noyau de système d'exploitation. Alors, bon, c'est pas vraiment un noyau, mais on va faire comme si. Et faire comme si, ça veut dire que notre programme ne rend jamais la main. D'ailleurs, à qui la rendrait-il ? Au secteur d'amorçage, qui a fini son travail il y a bien longtemps ? Non. Il ne rendra pas la main. Ainsi, au lieu du "ret" final, ce sera "jmp $", qui fait une boucle infinie. D'autre part, il n'y aura pas d'en-tête du système d'exploitation, vu que le système d'exploitation, c'est lui. Comme le secteur d'amorçage, nous aurons "org 0".

VI.5. Des 45 tours

Comment va-t-on utiliser notre programme ?

Conceptuellement, nous allons en faire une disquette de démarrage, oui, une disquette dite "de boot" ! On compile notre programme normalement. Ensuite, on copie bout à bout notre secteur d'amorçage et notre programme dans un seul fichier. Sous Windows, DOS et Cie, la commande est :

Sélectionnez
copy amorce.com/B+programme.com/B disk.img /Y

"/B" signifie qu'il s'agit d'un fichier binaire, ce qui évitera au DOS de le modifier. "/Y" évite d'avoir la confirmation de réécriture qui ne manque pas d'arriver dès la deuxième fois.

Sous système Unix, je ne connais pas la commande.

Le fichier copié est la disquette de démarrage.

Dans le code assembleur, on termine le fichier comme pour le secteur d'amorçage, avec un "times 4096 - ($ - $$) db 0". Cela sert le même objectif : que le fichier compilé fasse un nombre entier de secteurs de disquette.

Il ne reste plus qu'à configurer le PC virtuel pour qu'il lise le fichier généré comme une disquette : soit on lui donne une vraie disquette qui ne contient que notre fichier, soit on lui précise que la disquette est en fait un fichier image.

Il ne reste plus qu'à démarrer la machine virtuelle, et voilà ! On retrouve notre programme.

VI.6. Du massacre de disque

Notre programme a été lancé sur une machine virtuelle, et surtout, qu'il en soit ainsi pour le moment : il y a un morceau de code qui écrit sur le premier disque dur. Si on l'exécutait sur un vrai ordinateur, on écrirait là où est le système d'exploitation, ce qui est toujours une mauvaise idée. Ce petit bout de code ne fait que recopier les premiers secteurs de la disquette sur les premiers secteurs du disque dur. En enlevant la disquette de la machine virtuelle, la machine virtuelle démarre toujours, et exécute notre programme.

VI.7. Du code

Le code de ce chapitre se trouve ici :

  • amorcage.asm
  • programme.asm


  • VII. Assembleur : reconstruction
    • VII.1. Du foutoir
    • VII.2. De l'affichage de texte
      • VII.2.a. affiche_chaine
      • VII.2.b. nombre_vers_chaine
    • VII.3. Des modes graphiques
      • VII.3.a. Saut au début, optionnel
      • VII.3.b. P'tit message pour ne pas perdre la main
      • VII.3.c. Demande d'informations sur le VESA BIOS EXTENSION
      • VII.3.d. Traitement de ces informations
      • VII.3.e. Choix du mode graphique
      • VII.3.f. Passage dans le mode choisi
        • VII.3.f.1. Nettoyage de l'écran
      • VII.3.g. Affichage de points
      • VII.3.h. Affichage de droites
      • VII.3.i. Affichage d'un alphabet
    • VII.4. Du code

VII. Assembleur : reconstruction

Relu par ClaudeLELOUP.

Depuis la création du secteur d'amorçage au chapitre précédent, je ne veux pas dire qu'on n'a plus rien, mais peu s'en faut. Les routines sont un peu bringuebalantes, tout ça. Cela nécessite une petite remise à niveau.

VII.1. Du foutoir

Il faut avouer ce qui est : il devient rude de retrouver quoi que ce soit dans le code. NASM permet de s'y retrouver un peu mieux en séparant le code en plusieurs fichiers. La directive %include "[nom du fichier]" permet d'insérer à cet endroit le contenu du fichier spécifié.

VII.2. De l'affichage de texte

Les routines d'affichage de texte ont été déportées dans un fichier "affichageTexte.asm". Il y a deux fonctions : une fonction qui affiche une chaîne de caractères à l'écran "affiche_chaine", et une fonction qui transforme un nombre en chaîne de caractères "nombre_vers_chaine". Il faut garder à l'esprit que ces fonctions ont vocation à disparaître, puisqu'elles fonctionnent en mode texte, qui disparaît rapidement. Elles sont reprises du chapitre 3, clarifiées, déboguées et ainsi de suite.

VII.2.a. affiche_chaine

Cette fonction affiche la chaîne de caractères pointée par si et se terminant par le caractère 0, aussi appelé caractère nul, de valeur numérique 0. Elle suppose que l'écran mesure 80 caractères de large sur 25 de haut. En cas de débordement, elle efface la page et continue son écriture en haut. Elle ne connaît que deux caractères non affichables : le 0 et le 13, respectivement marqueur de fin de chaîne et retour à la ligne.

Sélectionnez

                    ;Affiche à l'écran une chaîne de caractères
                    affiche_chaine:
                        push ax
                        push bx
                        push cx
                        push dx
                        xor bh, bh; RAZ de bh, qui stocke la page d'affichage
                        mov ah, 0x03
                        int 0x10; appel de l'interruption BIOS qui donne la position du curseur, stockée dans dx
                        mov cx, 1; nombre de fois où l'on va afficher un caractère
                    affiche_suivant:
                        lodsb
                        or al, al;on compare al à zéro pour s'arrêter
                        jz fin_affiche_suivant
                        cmp al, 13
                        je nouvelle_ligne
                        mov ah, 0x0A;on affiche le caractère courant cx fois
                        int 0x10
                        inc dl; on passe à la colonne suivante pour la position du curseur
                        cmp dl, 80
                        jne positionne_curseur
                    nouvelle_ligne:
                        inc dh; on passe à la ligne suivante
                        xor dl, dl; colonne 0
                        cmp dh, 25
                        jb positionne_curseur
                        xor dh, dh
                        mov ah, 0x02;on positionne le curseur
                        int 0x10
                        mov cx, 25*80
                        mov ah, 0x0A;on affiche le caractère courant cx fois
                        mov al, ' '
                        int 0x10
                        mov cx, 1
                    positionne_curseur:
                        mov ah, 0x02;on positionne le curseur
                        int 0x10
                        jmp affiche_suivant
                    fin_affiche_suivant:
                        pop dx
                        pop cx
                        pop bx
                        pop ax
                        ret

VII.2.b. nombre_vers_chaine

Cette fonction écrit dans la chaîne pointée par di le nombre contenu dans ax.

Sélectionnez

                    ;écrit dans la chaîne pointée par DI le nombre contenu dans AX
                    ;si CL est à un, on écrit un caractère terminal
                    ;CH contient le nombre minimal de caractères à utiliser
                    ;BL contient la base
                    nombre_vers_chaine:
                        push bx
                        push dx
                        push cx
                        xor bh, bh
                        mov cl, 1
                    stocke_digit:
                        xor dx, dx
                        div bx
                        push dx ;sauve le reste dans la pile
                        inc cl
                        or ax, ax
                        jne stocke_digit
                    ;Les digits sont stockés dans la pile
                        inc bh ;
                    ajout_zero:
                        cmp ch, cl
                        jb clearCH
                        push ' '-'A'+10
                        inc cl
                        jmp ajout_zero
                    clearCH:
                        xor ch, ch
                    ;Affichage du chiffre
                    boucle_digit:
                        dec bh
                        loop affiche_digit
                        pop cx ; on récupère le paramètre bx
                        test cl, 0b1 ; s'il est à 1, on écrit un caractère terminal
                        jz depile
                        mov byte [di], 0
                    depile:
                        pop dx
                        pop bx
                    ret

                    affiche_digit:
                        pop ax
                        cmp ax, 10
                        jae dixPlus
                        add ax, '0'
                        stosb ; met AL dans l'octet pointé par DI et incrémente DI
                        jmp boucle_digit
                    dixPlus:
                        add ax, 'A' - 10
                        stosb
                        jmp boucle_digit
                    ;fin nombre_vers_chaine

VII.3. Des modes graphiques

Pour faire une sorte de séance de révision, nous allons retravailler les graphiques du chapitre 5. Reprenons tout le déroulement du programme :

  • saut au début, optionnel ;
  • p'tit message pour ne pas perdre la main ;
  • demande d'informations sur le VESA BIOS EXTENSION ;
  • traitement de ces informations ;
  • choix du mode graphique ;
  • passage dans le mode choisi ;
  • affichage de points ;
  • affichage de droites ;
  • affichage d'un alphabet.

VII.3.a. Saut au début, optionnel

Sélectionnez

                org 0x0000 ; Adresse de début .COM

                jmp start
                %include "affichageTexte.asm"

                start:
                ;On initialise Data Segment et Extra Segment à Code Segment
                    call initialise_segments

On s'en souvient, on est en binaire pur pour être chargé depuis un chargeur d'amorçage, on a gardé la directive org d'une inutilité certaine ici. On fait un saut pour inclure les fonctions d'affichage de texte au début. C'est un choix, absolument pas justifiable par autre chose que "c'est comme ça". On appelle une fonction "initialise_segments" qui ne sera jamais appelée qu'une fois. On pourrait la mettre en macro, mais nous verrons les macros plus tard. C'est donc encore en fonction.

Sélectionnez

                ;Initialisation des segments
                initialise_segments:
                    mov ax, cs
                    mov ds, ax
                    mov es, ax
                    mov ax, 0x8000
                    cli
                    mov ss, ax
                    mov sp, 0xF000
                    sti
                    ret
                ;Fin initialisation segments

VII.3.b. P'tit message pour ne pas perdre la main

Sélectionnez

                    mov si, hello; met l'adresse de la chaîne à afficher dans le registre SI
                    call affiche_chaine

Bon, le message sera affiché, mais tel quel, on a d'excellentes chances de ne pas le voir, parce qu'on va afficher plus d'un écran par la suite. Il faut modifier le code qui viendra après pour le voir.

VII.3.c. Demande d'informations sur le VESA BIOS EXTENSION

Sélectionnez

                    mov ax, 0x4F00 ; demande infos sur le pilote VESA VBE
                    mov di, VESASignature
                    int 10h
                    cmp ax, 0x4F ; Si AL != 0x4F, on n'a pas de VESA, donc fin. Si AH != O, erreur, donc fin.
                    jne fin

VESASignature est défini dans la partie "données" :

Sélectionnez

                ;Informations du pilote VESA
                VESASignature: times 4 db 0; "VESA", signature de 4 octets
                VESAVersion: dw 0; numéro de version de VBE
                OEMStringPtr: dd 0; Pointeur vers le nom de l'OEM
                Capabilities: dd 0; Possibilités de la carte graphique
                VideoModePtr: dd 0; Pointeur vers les modes accessibles
                TotalMemory: dw 0; Nombre de blocs mémoire de 64ko
                reserved: times 236 db 0; Complément à 256 octets, taille du bloc

Ces informations, je ne les sors pas de mon chapeau, évidemment. Elles proviennent d'ici : VESA BIOS Extension (VBE) Core Functions Standard Version 2.0, tout ça parce que dans ma machine virtuelle, c'est VBE 2.0 qui est implanté.

VII.3.d. Traitement de ces informations

Le traitement des informations VESA consiste principalement en deux choses : gérer les cas d'erreur et pousser plus avant les investigations. Pour les erreurs, on restera très simple puisqu'on sort au moindre problème. Pour le reste, voyons cela.

Première chose, afficher le nom du constructeur :

Sélectionnez

                    cmp ax, 0x4F ; Si AL != 0x4F, on n'a pas de VESA, donc fin. Si AH != O, erreur, donc fin.
                    jne fin
                    mov si, OEMStringPtr ; pointeur vers le nom de l'OEM stocké offset:segment
                    lodsw; on charge l'adresse d'offset dans ax
                    mov bx, ax ; BX contient l'adresse d'offset
                    lodsw ; on charge l'adresse de segment dans ax
                    mov si, bx ; SI pointe sur le nom de l'OEM
                    push ds ; on sauvegarde DS
                        mov ds, ax ; ds contient l'adresse de segment du nom de l'OEM
                        call affiche_chaine
                    pop ds ; on restaure DS

                    mov cx, 18
                lignes_vides:
                    mov si, retour_chariot
                    call affiche_chaine
                    loop lignes_vides

Notez qu'on ajoute 18 lignes de retour chariot. C'est bête, c'est méchant et ça ne sert qu'à décaler l'affichage pour en avoir plus à visualiser sur l'écran suivant. Cette horreur devrait partir prochainement.

Nous allons ensuite lire les informations de chaque mode supporté par notre interruption 0x10. Ca nous permettra de choisir un bon mode vidéo.

Sélectionnez

                    mov si, VideoModePtr ; pointeur vers la liste des modes supportés
                    lodsw ; on charge l'adresse d'offset dans ax
                    mov cx, ax ; cx contient l'adresse d'offset
                    lodsw ; on charge l'adresse de segment dans ax
                    mov si, cx ; si pointe sur le premier mode supporté
                    mov dx, ax ; dx contient l'adresse de segment
                lit_mode_suivant:
                    push ds
                        mov ds, dx ; ds contient l'adresse de segment de la liste des modes
                        lodsw ;charge dans ax le mode
                    pop ds
                    cmp ax, 0xFFFF ; Fin de la liste
                    je arret_modes
                    mov cx, ax
                    mov ax, 0x4F01 ; demande infos sur le mode VESA
                    mov di, ModeAttributes
                    int 0x10

VideoModePtr est défini dans le bloc de données :

Sélectionnez

                ;Informations d'un mode vidéo
                ModeAttributes: dw 0; Attributs du mode
                WinAAttributes: db 0; Attibuts de la fenêtre A
                WinBAttributes: db 0; Attibuts de la fenêtre B
                WinGranularity: dw 0; Granularité de la fenêtre en ko
                WinSize: dw 0; Taille de la fenêtre en ko
                WinASegment: dw 0; Segment de la fenêtre A
                WinBSegment: dw 0; Segment de la fenêtre B
                WinFuncPtr: dd 0; Pointeur vers la fonction "de fenêtrage"
                BytesPerScanLine: dw 0; Octets par "scanline"
                XResolution: dw 0; Résolution horizontale
                YResolution: dw 0; Résolution verticale
                XCharSize: db 0; Largeur d'un caractère
                YCharSize: db 0; Hauteur d'un caractère
                NumberOfPlanes: db 0; Nombre de plans mémoire
                BitsPerPixel: db 0; Bits par pixel
                NumberOfBanks: db 0; Nombre de banques de style CGA
                MemoryModel: db 0; Type de modèle mémoire
                BankSize: db 0; Taille des banques de style CGA
                NumberOfImagePages: db 0; Nombre de pages image
                res1: db 0; Reservé
                RedMaskSize: db 0; Taille du masque rouge en couleur directe
                RedFieldPosition: db 0; Position du bit faible du masque rouge
                GreenMaskSize: db 0; Taille du masque vert en couleur directe
                GreenFieldPosition: db 0; Position du bit faible du masque vert
                BlueMaskSize: db 0; Taille du masque bleu en couleur directe
                BlueFieldPosition: db 0; Position du bit faible du masque bleu
                RsvdMaskSize: db 0; Taille du masque réservé en couleur directe
                RsvdFieldPosition: db 0;  Position du bit faible du masque réservé
                DirectColorModeInfo: db 0; Attributs du mode de couleur directe
                res2: times 216 db 0; Complément à 256 octets, taille du bloc

Le test d'erreurs :

Sélectionnez

                    cmp ax, 0x4F ; Si AL != 0x4F, la fonction n'est pas supportée, on se contentera du VGA. Si AH != O, erreur, pareil.
                    jne lit_mode_suivant
                    test word [ModeAttributes], 0xF
                    jz lit_mode_suivant

On affiche les informations pertinentes du mode courant :

Sélectionnez

                ;On ecrit les modes
                    mov di, hello ; on écrit dans hello
                    mov ax, cx
                    push cx
                        mov ch, 3
                        mov bl, 16
                        call nombre_vers_chaine
                        mov al, ':'
                        stosb
                        mov ch, 4
                        mov bl, 10
                        mov ax, [XResolution]
                        call nombre_vers_chaine
                        mov ax, ('*' << 8) + ' '
                        stosw
                        mov al, ' '
                        stosb
                        mov ax, [YResolution]
                        call nombre_vers_chaine
                        mov ax, ' '
                        stosb
                        mov ch, 2
                        mov al, [BitsPerPixel]
                        call nombre_vers_chaine
                        mov al, ' '
                        stosb
                        mov ax, ';' ; on met 2 caractères d'un coup après la chaîne : un "\n" et le zéro terminal.
                        stosw ; les caractères sont dépilés, c'est-à-dire qu'il faut placer le premier dans la zone basse
                    pop cx
                        push si ;sauve si sur la pile
                        mov si, hello
                        call affiche_chaine
                    pop si ; on récupère si

Les esprits chagrins remarqueront que j'écris sur l'emplacement hello. Alors, il est fait pour cela, et j'en suis parfaitement conscient. Vu le peu que j'écris, j'ai largement la place dans cet espace pour mettre ce qui m'intéresse.

On va déborder un peu sur le paragraphe suivant : on va chercher quelle est la meilleure résolution à utiliser. On va la définir comme étant celle maximisant largeurEnPixels * hauteurEnPixels * TailleDeCodageDesCouleurs. Ca se fait dans la même boucle, c'est pour cela qu'on en parle maintenant.

Sélectionnez

                    push dx
                        mov ax, [XResolution]
                        shr ax, 5
                        push ax
                            mov ax, [YResolution]
                            shr ax, 3
                            push ax
                                mov al, [BitsPerPixel]
                                xor ah, ah
                                shr ax, 3
                            pop bx
                            mul bx
                        pop bx
                        mul bx
                    pop dx
                    cmp ax, [maxResol]
                    jb lit_mode_suivant
                    mov [maxResol], ax
                    mov [mode_souhaite], cx
                    jmp lit_mode_suivant

VII.3.e. Choix du mode graphique

Sélectionnez

                arret_modes:
                    mov cx, [mode_souhaite] ; On s'enquiert du mode souhaité
                    mov ax, 0x4F01 ; demande infos sur le mode VESA
                    mov di, ModeAttributes
                    int 0x10
                    mov di, hello ; on écrit dans hello
                    mov ax, cx
                    push cx
                        mov ch, 3
                        mov bl, 16
                        call nombre_vers_chaine
                        mov al, ':'
                        stosb
                        mov ch, 4
                        mov bl, 10
                        mov ax, [XResolution]
                        call nombre_vers_chaine
                        mov ax, ('*' << 8) + ' '
                        stosw
                        mov al, ' '
                        stosb
                        mov ax, [YResolution]
                        call nombre_vers_chaine
                        mov ax, ' '
                        stosb
                        mov ch, 2
                        mov al, [BitsPerPixel]
                        call nombre_vers_chaine
                        mov ax, 13 ; on met 2 caractères d'un coup après la chaîne : un "\n" et le zéro terminal.
                        stosw ; les caractères sont dépilés, c'est-à-dire qu'il faut placer le premier dans la zone basse
                    pop cx
                    mov si, hello
                    call affiche_chaine

                    mov al, [BitsPerPixel]
                    shr al, 3
                    mov byte [octetsParPixel], al
                    mov ax, [WinASegment]
                    or ax, ax ; on teste l'adresse du segment de la fenêtre. Si elle est nulle, on passe en mode 0x13
                    jnz adresse_OK
                adresse_mode_13h:
                    mov word [mode_souhaite], 0x0013 ; infos du mode 0x13, le mode VGA
                    mov word [WinASegment], 0xA000
                    mov word [YResolution], 200
                    mov word [XResolution], 320
                    mov byte [octetsParPixel], 1
                adresse_OK:
                    mov di, hello ; met l'adresse de la chaîne à lire dans le registre SI
                    call lit_chaine ; On attend l'utilisateur pour nettoyer l'écran

VII.3.f. Passage dans le mode choisi

Sélectionnez

                    mov ax, 0x4F02
                    mov bx, [mode_souhaite]
                    int 0x10 ; Changement de mode vidéo
                    call nettoyage_ecran
VII.3.f.1. Nettoyage de l'écran

Derrière ce terme ménager se cache juste le remplissage de l'intégralité de l'écran avec une couleur de fond. Comme d'habitude, l'idée n'est pas l'optimisation, le code super rapide comme l'ont tous les logiciels spécialisés. L'idée est d'avoir d'abord un code fonctionnel, créé au plus évident. L'algorithme que je vous propose ne prend en compte aucun prérequis autre qu'un mode VESA disposant d'une fenêtre A en mode écriture. Enfin, il me semble.

On écrit dans la mémoire vidéo par bloc de 64 ko. L'idée est donc de remplir tous ces blocs par la couleur de fond.

Sélectionnez

                    nettoyage_ecran:
                        push di
                        push es
                        push ax
                        push bx
                        push cx
                        push dx
                        mov es, [WinASegment]; On lit l'adresse de départ de la mémoire vidéo

                        mov cx, [YResolution]
                        mov ax, [XResolution]
                        mul cx	; Nombre de points total
                        mov ax, dx
                        mov cl, [octetsParPixel]
                        mul	cl
                        mov cx, ax
                        xor dx, dx
                        xor bh, bh
                        xor bl, bl
                    boucle_fenetres:
                        push cx
                            mov ax, 0x4F05
                            int 0x10 ; Changement de fenêtre
                            mov cx, [WinSize]
                            shl cx, 9 ;Passage de ko en mots.
                            xor di, di
                        .point:
                            push cx
                            push bx
                                mov cl, [octetsParPixel]
                                xor ch, ch
                                mov bx, couleur_defaut
                            .couleur:
                                inc bx
                                mov al, byte [bx]
                                stosb
                                loop .couleur
                            pop bx
                            pop cx
                            loop .point
                            inc dx
                        pop cx
                        loop boucle_fenetres
                    .depile:
                        xor dx, dx
                        mov [bloc_courant], dl
                        mov ax, 0x4F05
                        int 0x10 ; Changement de fenêtre
                        pop dx
                        pop cx
                        pop bx
                        pop ax
                        pop es
                        pop di
                        ret
                    ;Fin nettoyage_ecran

VII.3.g. Affichage de points

Avec cette fonction, vous pouvez dorénavant dessiner ce que vous voulez où vous le voulez, en, normalement, n'importe quel mode VESA ! L'idée de base reste de mettre, octet par octet, la couleur spécifiée à l'adresse couleurPoint dans la mémoire vidéo, et de donner le bon numéro de plage. Ce bon numéro de plage est toujours basé sur la sainte formule : ordonnée * octetsParLigne + abscisse. Je suppose que le débordement de la multiplication me donne le numéro convoité.

Sélectionnez

                ;fonction affiche_point : on est déjà dans un mode graphique
                ;BX : Coordonnée X du point
                ;AX : Coordonnée Y du point
                affiche_point:
                    push bx ; On sauve les registres qu'on va manipuler
                    push cx
                    push es
                    push di
                    push dx
                    push ax
                        mov cx, word [BytesPerScanLine]
                        mul	cx
                        mov di, ax
                        push dx
                            mov ax, bx
                            xor ch, ch
                            mov cl, byte [octetsParPixel]
                            mul cx
                            add di, ax
                        pop dx
                    .change_fenetre:
                        mov ax, 0x4F05
                        xor bh, bh
                        xor bl, bl
                        int 0x10 ; Changement de fenêtre
                        mov es, [WinASegment] ; On va dans la mémoire vidéo
                        mov bx, couleurPoint
                    .couleur:
                        inc bx
                        mov al, byte [bx]
                        stosb
                        loop .couleur
                    pop ax ; On restaure les registres manipulés
                    pop dx
                    pop di
                    pop es
                    pop cx
                    pop bx
                    ret
                ;fin de affiche_point

VII.3.h. Affichage de droites

On avait déjà parlé de l'algorithme de Bresenham, le revoici à l'identique. Il a été mis à jour pour refléter l'inversion des paramètres de affiche_point. On met sur la pile X1, puis Y1, puis X2 et enfin Y2.

Sélectionnez

                ;fonction affiche_ligne : on est déjà dans un mode graphique
                affiche_ligne:
                    jmp depart_affiche_ligne
                Y2: dw 0
                X2: dw 0
                Y1: dw 0
                X1: dw 0
                deltaX: dw 0
                deltaY: dw 0
                incX: dw 0
                incY: dw 0
                e: dw 0
                depart_affiche_ligne:
                    push si
                    push ax
                    push bx
                    push cx
                    push dx
                    push di
                    push es
                    mov ax, sp
                    mov si, ax
                    add si, 16 ; SI pointe sur Y2
                    mov di, Y2
                    mov ax, ds
                    mov es, ax
                    mov ax, ss
                    mov ds, ax
                    mov cx, 4
                    rep movsw
                    mov ax, es
                    mov ds, ax
                    mov ax, [X2]
                    mov bx, [X1]
                    sub ax, bx
                    mov [deltaX], ax
                    mov cx, [Y2]
                    mov bx, [Y1]
                    sub cx, bx
                    mov [deltaY], cx
                    or ax, ax ; test deltaX
                    jnz test_deltaX_positif
                    or cx, cx ; test deltaY
                    jnz test_deltaY_deltaX_nul
                fin_affiche_ligne:
                    mov bx, [X2]
                    mov ax, [Y2]
                    call affiche_point
                    pop es
                    pop di
                    pop dx
                    pop cx
                    pop bx
                    pop ax
                    pop si
                    ret

                deltaX_positif:
                    or cx, cx
                    jnz test_deltaY_deltaX_positif
                    ;vecteur horizontal vers la droite
                    mov cx, [deltaX]
                    mov word [incX], 1
                    mov word [incY], 0
                    jmp ligne_H_V

                test_deltaY_deltaX_nul:
                ;cx contient deltaY
                    mov word [incY], 1
                    mov word [incX], 0
                    cmp cx, 0
                    jns ligne_H_V
                    neg cx
                    mov word [incY], -1
                ligne_H_V:
                    mov bx, [X1]
                    mov ax, [Y1]
                avance_H_V:
                    call affiche_point
                    add bx, [incX]
                    add ax, [incY]
                    loop avance_H_V
                    jmp fin_affiche_ligne

                test_deltaX_positif:
                    cmp ax, 0
                    jns deltaX_positif
                    or cx, cx ; CX contient DeltaY
                    jnz test_deltaY_deltaX_negatif
                    ;vecteur horizontal vers la gauche
                    mov cx, [deltaX]
                    neg cx
                    mov word [incX], -1
                    mov word [incY], 0
                    jmp ligne_H_V

                charge_registres:
                    shl cx, 1
                    shl ax, 1
                    mov [deltaY], cx
                    mov [deltaX], ax
                    mov bx, [X1]
                    mov ax, [Y1]
                    ret

                charge_e_deltaX_et_cmp_X2:
                    mov [e], ax
                    call charge_registres
                    mov cx, [X2]
                    ret

                charge_e_deltaY_et_cmp_Y2:
                    mov [e], cx
                    call charge_registres
                    mov cx, [Y2]
                    ret

                affiche_et_charge_eY:
                    call affiche_point
                    add ax, [incY]
                    mov dx, [e]
                    ret

                affiche_et_charge_eX:
                    call affiche_point
                    add bx, [incX]
                    mov dx, [e]
                    ret

                octants1_et_4:
                    call charge_e_deltaX_et_cmp_X2
                depart_boucle1:
                    call affiche_et_charge_eX
                    cmp bx, cx
                    je fin_affiche_ligne
                    sub dx, [deltaY]
                    cmp dx, 0
                    jns X_pret1
                    add ax, [incY]
                    add dx, [deltaX]
                X_pret1:
                    mov [e], dx
                    jmp depart_boucle1

                deltaY_positif_deltaX_negatif:
                    neg ax
                deltaY_positif_deltaX_positif:
                    mov word [incY], 1
                    ;deltaY > 0, deltaX > 0
                    cmp ax, cx
                    jae octants1_et_4
                    neg ax
                    call charge_e_deltaY_et_cmp_Y2
                depart_boucle2_et_3:
                    call affiche_et_charge_eY
                    cmp ax, cx
                    je fin_affiche_ligne
                    add dx, [deltaX]
                    cmp dx, 0
                    jns X_pret2_et_3
                    add bx, [incX]
                    add dx, [deltaY]
                X_pret2_et_3:
                    mov [e], dx
                    jmp depart_boucle2_et_3

                octant5:
                    call charge_e_deltaX_et_cmp_X2
                depart_boucle5:
                    call affiche_et_charge_eX
                    cmp bx, cx
                    je fin_affiche_ligne
                    sub dx, [deltaY]
                    cmp dx, 0
                    js X_pret5
                    add ax, [incY]
                    add dx, [deltaX]
                X_pret5:
                    mov [e], dx
                    jmp depart_boucle5

                octant8:
                    neg cx
                    call charge_e_deltaX_et_cmp_X2
                depart_boucle8:
                    call affiche_et_charge_eX
                    cmp bx, cx
                    je fin_affiche_ligne
                    add dx, [deltaY]
                    cmp dx, 0
                    jns X_pret8
                    add ax, [incY]
                    add dx, [deltaX]
                X_pret8:
                    mov [e], dx
                    jmp depart_boucle8

                test_deltaY_deltaX_positif:
                    mov word [incX], 1
                    cmp cx, 0
                    jns deltaY_positif_deltaX_positif
                    ;deltaY < 0, deltaX > 0
                    mov word [incY], -1
                    neg cx
                    cmp ax, cx
                    jae octant8
                    neg cx
                    jmp octants6_et_7
                test_deltaY_deltaX_negatif:
                    mov word [incX], -1
                    cmp cx, 0 ; cx contient deltaY
                    jns deltaY_positif_deltaX_negatif
                    ;deltaY < 0, deltaX < 0
                    mov word [incY], -1
                    cmp ax, cx ; ax contient deltaX
                    jbe octant5
                    neg ax
                octants6_et_7:
                    call charge_e_deltaY_et_cmp_Y2
                depart_boucle6_et_7:
                    call affiche_et_charge_eY
                    cmp ax, cx
                    je fin_affiche_ligne
                    add dx, [deltaX]
                    cmp dx, 0
                    js X_pret6_et_7
                    add bx, [incX]
                    add dx, [deltaY]
                X_pret6_et_7:
                    mov [e], dx
                    jmp depart_boucle6_et_7
                ;AFFICHE_LIGNE ENDP

VII.3.i. Affichage d'un alphabet

Voici la grande nouveauté de ce chapitre : un alphabet ! En mode graphique, oui madame, dessiné par mes soins, à la va-comme-j'te-pousse. Je l'ai fait parce qu'il me semble que bientôt, nous n'en aurons plus. Et puis, ça permettra d'afficher facilement des "é", "ç" et autres "à". Dès que j'aurai mis ces caractères, bien sûr. La fonction est très simple : on lui donne le numéro du caractère, avec A = 1, dans cx. ax contient l'ordonnée du coin supérieur gauche du caractère et bx son abscisse.

Sélectionnez

                affiche_caractere:
                    push bx
                    push dx
                    push si
                    push cx
                    push ax
                    push ax
                    mov ax, cx
                    mov cx, 6
                    mul cx
                    mov si, Alphabet; adresse de la lettre
                    add si, ax; si contient l'adresse de la lettre
                .colonne:
                    pop ax
                    push cx
                    mov dl, 0b10000000
                    mov cx, 8; On affiche 8 colonnes
                .ligne:
                    push ax
                    mov al, [si]; On charge l'octet à afficher
                    test al, dl
                    jz .suite
                    pop ax
                    call affiche_point
                    push ax
                .suite:
                    shr dl, 1
                    inc bx
                    pop ax
                    loop .ligne
                    pop cx
                    inc ax; passage à la ligne suivante
                    sub bx, 8
                    push ax
                    inc si
                    loop .colonne
                    pop ax
                    pop ax
                    pop cx
                    pop si
                    pop dx
                    pop bx
                    ret

VII.4. Du code

  • amorce.asm
  • affichageTexte.asm
  • alphabet.asm
  • ecran.asm


  • VIII. Assembleur : passage en mode protégé
    • VIII.1. Histoire de l'informatique
      • VIII.1.a. L'architecture des premiers Personal Computers
      • VIII.1.b. Le piège d'IBM
      • VIII.1.c. Le mode protégé
    • VIII.2. Problèmes avec le mode protégé
    • VIII.3. Solutions en mode protégé
      • VIII.3.a. Le GDT (Global Descriptor Table)
      • VIII.3.b. Les Descriptors
    • VIII.4. De la pratique

VIII. Assembleur : passage en mode protégé

Relu par ClaudeLELOUP.

Au chapitre précédent, nous avons patiemment retrouvé le contrôle de l'écran, dans ses grandes lignes. A une phase de reconstruction suit une phase de destruction. Il fallait que je m'y colle il y a longtemps déjà, j'ai écumé le net et la documentation AMD, je lis l'anglais comme un américain maintenant, voilà, petits Frenchies, voilà où nous en sommes : le mode protégé !

Les plus futés auront remarqué que nous n'avons manipulé jusqu'à présent que des registres de 16 bits. Or, nous avons tous, au moins, des machines 32 bits. Nous avons utilisé des adresses de la forme segment:offset, codées sur 20 bits par une arithmétique assez odieuse, alors qu'un seul registre de 32 bits nous aurait évité cela. Il y avait une raison. Une vraie raison, officielle en diable : pour faire mieux, il faut relever le challenge des débutants, passer en mode protégé. Alors, je vais faire un petit topo sur tout ça.

VIII.1. Histoire de l'informatique

Un bien pompeux titre, mais ne croyez pas tout ce qu'on vous raconte. Il ne s'agit ici que de prendre en compte de simples considérations historiques qui nous expliquent pourquoi on en est là.

VIII.1.a. L'architecture des premiers Personal Computers

C'est la société IBM (dont le nom signifie quelque chose comme "machines intelligentes pour les affaires") qui a gagné le marché des ordinateurs personnels. N'oublions pas qu'à cette époque (le début des années 1980), les ordinateurs existent, sont assez répandus mais ne sont pas non plus à la disposition de tout un chacun. IBM va jeter les bases de l'informatique personnelle. Bien évidemment, la concurrence est rude. IBM va choisir de fabriquer des ordinateurs de série à bas coût, avec des processeurs qu'elle peut produire en nombre.

Et il se trouve que ce sera un succès. Modeste par rapport au nombre d'ordinateurs vendus quotidiennement aujourd'hui, mais suffisamment important pour qu'IBM occupe une grosse part de marché. Les développeurs vont donc fournir des programmes développés pour cette machine, l'IBM PC, basée sur un processeur 8086 (en fait un 8088, mais c'est le 8086 qui a légué son nom à la postérité). Les utilisateurs de machines vont ensuite vouloir faire fonctionner ces mêmes programmes sur d'autres machines. Cela n'est possible que si les machines sont compatibles. Au vu des parts de marché d'IBM, la concurrence est obligée de s'aligner et d'adopter la même architecture que celle d'IBM.

Cette architecture permet d'accéder à 220 octets de mémoire vive, plus ou moins un. Ca fait 1 mégaoctet. Mais c'est une machine 16 bits, ce qui fait qu'elle ne peut représenter que 216 octets, soit 64 kilooctets. Pour adresser 1 Mo, elle doit ruser. La ruse consiste à utiliser deux zones mémoire du processeur pour adresser toute la mémoire. Du coup, on a deux fois 16 bits, ce qui couvre nos 20 bits d'adresse. Oui, mais deux fois 16 bits, ça fait 32 bits, on en a trop. Et c'est à ce moment, je ne sais pas pourquoi mais ils avaient leurs raisons, que les collègues de chez IBM ont décidé que leurs deux nombres recouvriraient en partie la zone d'adressage. Il y a 12 bits qui se recouvrent ! 0x1222:2220 correspond à exactement la même case mémoire que 0x1444:0000 ou 0x1000:4440 ou 1111:3330 ! Ce truc délirant, ça s'appelle l'arithmétique des pointeurs.

VIII.1.b. Le piège d'IBM

Peut-être que les gars qui ont pondu ça, ils étaient fatigués, sous pression, les commerciaux leur ont dit que c'était juste un petit truc comme ça, que sais-je. Mais le fait est qu'on a eu l'arithmétique des pointeurs. Et que l'IBM PC a eu un succès fou. Là est tout le drame. Car non seulement la concurrence a dû faire des machines compatibles, mais de surcroît IBM aussi ! Quand l'entreprise a voulu enlever ces zones mémoire qui se recouvraient, par souci de compatibilité, elle n'a pas pu. Néanmoins, il fallait obligatoirement dépasser cette limite de 1 Go de mémoire, et quitter cette méchante façon d'adresser les octets. Que faire ?

VIII.1.c. Le mode protégé

IBM a inventé le mode protégé. Appelons le mode historique le mode réel. Dans un ordinateur en mode réel, n'importe quel programme peut voir l'ensemble de la mémoire et l'écrire. Cela peut poser des problèmes, notamment quand votre voisin décide d'écrire chez vous. Il faut une astuce pour rendre la chose plus difficile.

Grâce à une série d'instructions à faire dans le bon ordre, de zones mémoire judicieusement choisies et d'un processeur le permettant (donc au moins un 80286), on peut utiliser 32 bits d'adressage direct et interdire à un programme d'aller voir en-dehors de l'espace qui lui est alloué. C'est cela, le mode protégé.

VIII.2. Problèmes avec le mode protégé

Il faut s'en douter, si on n'était pas en mode protégé jusqu'à présent, c'est que ce mode pose des problèmes qu'on peut apprécier ne pas avoir. Le plus gros et le plus velu, de mon point de vue, est celui-ci : en mode protégé, adieu les interruptions. Fini les services du BIOS. Plus de changement de mode graphique. Plus d'affichage de caractère, plus de clavier. Plus rien. Il faut tout refaire. Tout.

VIII.3. Solutions en mode protégé

Bon, ben quand faut y aller, faut y aller.

Techniquement, pour passer en mode protégé, il suffit de ceci :

Sélectionnez

                mov eax,cr0
                or ax,1
                mov cr0,eax

En langage humain, il suffit de passer le bit n° 0 du registre CR0 à 1. Comme le registre CR0 n'est pas éditable par le microprocesseur, on le passe d'abord dans EAX, on fait le OR qui permet de mettre le bit n° 0 à 1 sans changer tout le reste, et on remet EAX dans CR0.

Mais ce n'est pas suffisant. En effet, en mode protégé, la mémoire peut être segmentée. C'est un vilain mot qui signifie qu'il faut définir des segments de mémoire. Les anciens registres, tels que CS, DS et ES existent toujours en mode protégé. Ils font toujours 16 bits, mais ce ne sont plus les bits de poids fort de l'adresse. Ils sont devenus des offsets, des décalages. Ils correspondent au décalage nécessaire pour atteindre le descripteur de segment correspondant dans le tableau global des descripteurs, Global Descriptor Table (GDT).

En mode protégé, le processeur va regarder le segment correspondant à son instruction, ajouter cette adresse à son registre GDT, lire le descripteur de segment à cet endroit, en conclure quant à l'adresse concernée et y aller.

VIII.3.a. Le GDT (Global Descriptor Table)

Qu'on appelle, en français, le tableau global des descripteurs. C'est une zone mémoire, à une adresse spécifiable comme bon nous semble. Elle est spécifiquement liée à deux instructions particulières, mais une seule nous intéresse ici : LGDT, Load Global Descriptor Table. Elle prend un seul argument, l'adresse (32 bits maintenant, donc) d'une toute petite zone mémoire, que nous allons appeler pointeurGDT:, comme c'est original. Cette zone contient deux nombres dans cet ordre :

  • la taille en octets du tableau global des descripteurs, sous forme de mot (16 bits) ;
  • l'adresse de début du tableau global des descripteurs, sous forme de double-mot (32 bits), nécessairement.

Comme le monde entier suppose tout à fait intelligemment que si on a une adresse et une taille de tableau à donner, c'est qu'il nous faut le remplir, et que s'il s'appelle "Tableau Global des Descripteurs", c'est qu'il doit contenir des descripteurs, voyons un peu les descripteurs.

VIII.3.b. Les Descriptors

Oui, les descripteurs. Un descripteur est une structure de données qui décrit, d'où son nom, quelque chose. J'ai parlé avant de segments en mémoire, et bien mettons les deux ensemble : dans le tableau global des descripteurs, les descripteurs décrivent des segments. Il en faut au moins deux : un pour le code, un autre pour les données. C'est comme ça, c'est imposé par le processeur. Par contre, on a le droit de décrire le même segment.

Le descripteur à proprement parler a la structure qui suit.

  • Base : adresse linéaire du début du segment, 32 bits : la limite peut être toute la mémoire adressable.
  • Limite : taille en octets du segment, 20 bits avec astuce. Normalement, on devrait avoir 32 bits.
  • G : drapeau. Si à 1, la Limite est donnée en octets. Sinon, elle est en multiples de 4096 octets. 1 bit. Permet de simuler les 32 bits attendus sur Limite.
  • S : drapeau. Si à 1, il s'agit d'un segment système (inaccessible aux autres programmes). Sinon, il s'agit d'un segment de code ou de données. 1 bit.
  • Type : type de segment (données, code, etc.). 4 bits.
  • DPL : niveau de privilège minimal pour accéder au segment. 2 bits.
  • P : Si à 1, le segment est présent dans la mémoire principale. 1 bit.
  • D/B : Taille des éléments du segment : opérandes en mode Code, pile en mode Données. 0 : 16 bits ou SP, 1 : 32 bits ou ESP. 1 bit.
  • AVL : champ utilisable par le programmeur. 1 bit.

Ce qui fait royalement 63 bits. Un bit ne sert à rien et porte le total à 8 octets, répartis comme suit (attention c'est stéganologique) :

Sélectionnez

                     15                       7                    0
                    --------------------------------------------------
                    | Limite, bits 0-15                              |
                    --------------------------------------------------
                    | Base, bits 0-15                                |
                    --------------------------------------------------
                    | P  DPL   S     Type    Base, bits 16-23        |
                    --------------------------------------------------
                    |    Base, bits 24-31     G  D/B 0 AVL Limite,fin|
                    --------------------------------------------------

Voici les valeurs possibles de Type.

  • 0 : Read Only. Lecture seule.
  • 1 : Read Only, accessed. Lecture seule, accédé ?
  • 2 : Read/Write. Lecture/Ecriture.
  • 3 : Read/Write, accessed. Lecture/Ecriture, accédé ?
  • 4 : Read Only, expand down. Lecture seule, s'accroît vers le bas.
  • 5 : Read Only, expand down, accessed. Lecture seule, s'accroît vers le bas, accédé ?
  • 6 : Read/Write, expand down. Lecture/Ecriture, s'accroît vers le bas.
  • 7 : Read/Write, expand down, accessed. Lecture/Ecriture, s'accroît vers le bas, accédé ?
  • 8 : Execute Only. Exécution seule.
  • 9 : Execute Only, accessed. Exécution seule, accédé ?
  • A : Execute/Read. Exécution/Lecture.
  • B : Execute/Read, accessed. Exécution/Lecture, accédé ?
  • C : Execute Only, conforming. Exécution seule, standard.
  • D : Execute Only, conforming, accessed. Exécution seule, standard, accédé ?
  • E : Execute/Read, conforming. Exécution/Lecture, standard.
  • F : Execute/Read, conforming, accessed. Exécution/Lecture, standard, accédé ?

Source : http://www.c-jump.com/CIS77/ASM/Protection/W77_0090_segment_descriptor_cont.htm

Nous avons besoin de trois segments.

  • Le segment NULL, nécessaire au processeur en cas d'erreur de segmentation. Tout à zéro, fin du match. gdt: db 0,0,0,0,0,0,0,0
  • Le segment de code. Limite à 0xFFFFF, Base à 0, P à 1, DPL à 0, S à 1, Type à Exécution/Lecture accédé, AVL à 1, D/B à 1 et G à 1. gdt_cs: db 0xFF,0xFF,0x0,0x0,0x0,10011011b,11011111b,0x0
  • Le segment de données. Limite à 0xFFFFF, Base à 0, P à 1, DPL à 0, S à 1, Type à Lecture/Ecriture accédé, AVL à 1, D/B à 1 et G à 1. gdt_ds: db 0xFF,0xFF,0x0,0x0,0x0,10010011b,11011111b,0x0

VIII.4. De la pratique

On l'a bien mérité, voici le secteur d'amorçage qui passe en mode protégé avant de donner la main à un noyau à venir.

Sélectionnez

?fine	BASE	0x100		; 0x0100:0x0 = 0x1000
?fine KSIZE	2
?fine BOOT_SEG 0x07c0

BITS 	16
org 	0x0000 ; Adresse de début bootloader

;; Initialisation des segments en 0x07C0
    mov 	ax, 	BOOT_SEG
    mov 	ds, 	ax
    mov 	es, 	ax
    mov 	ax, 	0x8000	; pile en 0xFFFF
    mov 	ss, 	ax
    mov 	sp, 	0xf000

;; Affiche un message
    mov 	si, 	msgDebut
    call afficher

;; Charge le noyau
initialise_disque: 			; Initialise le lecteur de disque
    xor 	ax, 	ax
    int 	0x13
    jc initialise_disque	; En cas d'erreur on recommence (sinon, de toute façon, on ne peut rien faire)

lire:
    mov 	ax, 	BASE 	; ES:BX = BASE:0000
    mov 	es, 	ax
    xor 	bx, 	bx
    mov 	ah, 	2 		; Fonction 0x02 : chargement mémoire
    mov 	al, 	KSIZE 	; On lit KSIZE secteurs
    xor 	ch, 	ch 		; Premier cylindre (n° 0)
    mov 	cl, 	2 		; Premier secteur (porte le n° 2, le n° 1, on est dedans, et le n° 0 n'existe pas)
    xor 	dh, 	dh 		; Tête de lecture n° 0
    ; Toujours pas d'identifiant de disque, c'est toujours le même.
    int 	0x13 			; Lit !
    jc lire 				; En cas d'erreur, on recommence

;; Passe en mode protégé
    cli
    lgdt 	[pointeurGDT]	; charge la gdt
    mov 	eax,	cr0
    or 		ax,		1
    mov 	cr0, 	eax		; PE mis a 1 (CR0)

    jmp next
next:
    mov 	ax, 	0x10	; offset du descripteur du segment de données
    mov 	ds, 	ax
    mov 	fs, 	ax
    mov 	gs, 	ax
    mov 	es, 	ax
    mov 	ss, 	ax
    mov 	esp,	0x9F000

    jmp dword 0x8:BASE << 4; réinitialise le segment de code

;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Synopsis: Affiche une chaîne de caractères se terminant par NULL	;;
;; Entrée:   DS:SI -> pointe sur la chaîne à afficher				;;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
afficher:
    push	ax
    push	bx
.debut:
    lodsb			; ds:si -> al
    cmp 	al, 0	; fin chaîne ?
    jz .fin
    mov 	ah, 0x0E	; appel au service 0x0e, int 0x10 du BIOS
    mov 	bx, 0x07	; bx -> attribut, al -> caractère ASCII
    int 0x10
    jmp .debut

.fin:
    pop bx
    pop ax
    ret

;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
msgDebut	db	"Chargement du kernel", 13, 10, 0

;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
gdt:
    db 0x00, 0x00, 0x00, 0x00, 0x00, 00000000b, 00000000b, 0x00
gdt_cs:
    db 0xFF, 0xFF, 0x00, 0x00, 0x00, 10011011b, 11011111b, 0x00
gdt_ds:
    db 0xFF, 0xFF, 0x00, 0x00, 0x00, 10010011b, 11011111b, 0x00
gdtend:

pointeurGDT:
    dw	gdtend-gdt				; taille
    dd	(BOOT_SEG << 4) + gdt	; base

;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Rien jusqu'à 510
times 510-($-$$) db 0
dw 0xAA55


  • IX. Assembleur : mode protégé - Retrouver ses interruptions
    • IX.1. Au fait, pourquoi a-t-on inventé les interruptions ?
    • IX.2. Et une interruption, c'est quoi ?
    • IX.3. Comment j'en fais ?
    • IX.4. Du PIC
      • IX.4.a. Du port d'entrée/sortie
      • IX.4.b. ICW1
      • IX.4.c. ICW2
      • IX.4.d. ICW3
      • IX.4.e. ICW4
      • IX.4.f. OCW1
      • IX.4.g. Fin de traitement d'interruption

IX. Assembleur : mode protégé - Retrouver ses interruptions

Relu par ClaudeLELOUP.

IX.1. Au fait, pourquoi a-t-on inventé les interruptions ?

C'est vrai, ça, pourquoi les interruptions ? La raison en est toute baignée de considérations calculatoires. Si le processeur devait regarder régulièrement si, par exemple, un caractère est en train d'être entré au clavier, mes pauvres enfants, on ne s'en sortirait pas et on aurait encore bien du mal à faire fonctionner un simulateur de calculette. Pour gagner énormément de temps et pour qu'aucun programmeur n'oublie de vérifier l'entrée clavier, c'est l'inverse que l'on fait : c'est le contrôleur clavier qui interrompt le processeur en lui disant : "Je ne sais pas si cela vous intéresse, mon bon ami, mais j'ai ici une touche qui vient d'être frappée." Ca, c'était pour la poésie. Dans la vraie vie, ça se passe plutôt comme "Touche. -6e procédure. -STOP ! 6e procédure. -6e procédure faite. -OK, j'y retourne." Et la 6e procédure en question, c'est l'interruption clavier. Et si un contrôleur de clavier peut appeler une interruption, alors un programme aussi. C'est ce détournement que nous avons utilisé précédemment, et c'est même prévu pour être utilisé à outrance.

IX.2. Et une interruption, c'est quoi ?

C'est un programme, sous-programme ou routine, peu importe le nom, dont l'exécution interrompt le cours normal d'un autre programme. Un peu comme quand l'exécution automatique d'un CD se déclenche quand vous êtes en train de rédiger votre site, par exemple. On est interrompu. Cette routine a ceci de particulier qu'elle se termine par l'instruction IRET, comme Interrupt RETurn, instruction qu'on ne trouve qu'en langage assembleur (ou alors d'autres langages parfaitement exotiques). Voici donc notre première routine de gestion d'interruption : entreeInterruption : IRET. Donc, pour appeler une routine définie comme interruption, on ne peut pas utiliser CALL. Ce sera INT.

IX.3. Comment j'en fais ?

Comme d'habitude chez nous, on va faire dans le simple, quitte à bulldozer un brin. Alors, en premier, on doit pratiquer la même chose que pour le passage en mode protégé. C'est-à-dire que l'on doit créer un IDT (Interrupt Descriptor Table), tableau de descripteurs d'interruption, contenant, comme c'est étrange, des descripteurs d'interruption. L'instruction INT prend un opérande de 8 bits, on peut donc avoir 256 descripteurs d'interruption. Voici comment je fais :

Sélectionnez

                mov eax, IDTBASE 				; Adresse de l'IDT
                mov ebx, interruptionParDefaut 	; On va remplir toute la table avec l'interruption par défaut : bx contient les bits de poids faible
                mov ecx, IDTSIZE				; Nombre de vecteurs d'interruption
                call metInterruptionNFois

                lidt [idtptr]	; charge l'idt
                sti

            ;;Met l'interruption n fois dans l'idt
            metInterruptionNFois:
                mov edx, ebx
                shr edx, 16
                mov [descripteurInterruption], bx	;bx contient les bits de poids faible
                mov [descripteurInterruption + 6], dx; et dx ceux de poids fort
                mov edi, eax
            .remplitIDTPIC:
                mov esi, descripteurInterruption
                movsd
                movsd
                loop .remplitIDTPIC
                ret

            interruptionParDefaut:
                iret

            idtptr:
                dw	IDTSIZE << 3			; limite
                dd	IDTBASE					; base

            descripteurInterruption:
                dw 0, 1 * 8, INTGATE, 0

Notez l'instruction LIDT qui fait exactement la même chose que LGDT mais pour l'IDT, et le rétablissement des interruptions l'instant d'après. Notez aussi que nous travaillons en 32 bits, gloria alleluia.

IX.4. Du PIC

Les périphériques d'un ordinateur compatible PC ne sont pas branchés directement sur le processeur. Ils sont branchés sur un élément qu'on appelle le PIC (Programable Interruption Controller), le contrôleur d'interruption programmable. Il y en a deux, l'un piloté par l'autre. Le pilote s'appelle le maître, l'autre l'esclave. La raison d'être du PIC est donc de déclencher des interruptions. Or, il se trouve que dans son nom, il y a "Programable", ce qui doit signifier qu'on peut faire quelques configurations. Le PIC n'étant ni la mémoire ni le processeur, pour y accéder, on va parler :

IX.4.a. Du port d'entrée/sortie

Notre ordinateur ne fait pas qu'écrire et lire de la mémoire. Il interagit aussi avec des choses, qui sont des appareils électroniques. Cette interaction se fait au travers de 8 fils qui relient tous ces appareils. Le processeur peut écrire et lire sur ce port par les instructions OUT et IN. Et il écrit et lit un octet. L'adresse de l'appareil connecté est donnée en premier paramètre : il faut la connaître. Pour le PIC, il s'agit de 0x20 et 0x21 pour le maître et 0xA0 et 0xA1 pour l'esclave.

IX.4.b. ICW1

C'est la première valeur à envoyer au PIC pour le configurer, notamment pour qu'il pointe vers nos interruptions. Première signifie ici le numéro d'ordre dans la séquence. C'est obligatoirement celle-là. ICW signifie Initialisation Command Word, mot de la commande d'initialisation. Voici sa structure :

Bit | 7 | 6 | 5 | 4 | 3 | 2 | 1 | 0
Valeur | A5-A7 |  |  | 1 | LTIM | ADI | SNGL | IC4
  • IC4: 0 = Pas de ICW4, 1 = ICW4 requis.
  • SNGL: 1 = Un seul PIC, 0 = D'autres PIC sont montés en cascade.
  • ADI: Intervalle d'adressage. Utilisé uniquement dans les 8085, pas dans les 8086 (donc pas pour nous).
  • LTIM: Mode de déclenchement des interruptions: 1 = toutes les lignes de requête d'interruption sont déclenchées par niveau. 0 = elles sont déclenchées par front. Pour nous, 0 c'est bien.
  • A5-A7: Utilisé uniquement dans les 8085.

Le PIC occupe deux adresses de port, ICW1 est sur la première.

Source : http://www.thesatya.com/8259.html

Ca va nous donner : 00010001b pour le maître, et 00010001b pour l'esclave.

Sélectionnez

                mov al, 0x11	; Initialisation de ICW1
                out 0x20, al	; maître
                out 0xA0, al	; esclave

IX.4.c. ICW2

ICW2 est sur la seconde adresse, et correspond à l'index de la première interruption dédiée au PIC dans l'IDT. Il est indiqué nécessairement après ICW1.

Sélectionnez

            ?fine INTERRUPTION_PIC_MAITRE 0x20
            ?fine INTERRUPTION_PIC_ESCLAVE 0x70
                mov al, INTERRUPTION_PIC_MAITRE	; Initialisation de ICW2
                out 0x21, al	; maître, vecteur de départ = 32
                mov al, INTERRUPTION_PIC_ESCLAVE
                out 0xA1, al	; esclave, vecteur de départ = 96

IX.4.d. ICW3

Pour le PIC maître, chaque bit x mis à 1 indique que l'IRQx est utilisée par un PIC esclave. Pour l'esclave, cela indique le numéro de l'IRQ qu'il utilise chez son maître. ICW3 est sur la seconde adresse. Le standard est de mettre l'esclave sur l'IRQ2.

Sélectionnez

                mov al, 0x04	; initialisation de ICW3
                out 0x21, al
                mov al, 0x02	; esclave
                out 0xA1, al

IX.4.e. ICW4

ICW4 est sur la seconde adresse.

Bit | 7 | 6 | 5 | 4 | 3 | 2 | 1 | 0
Valeur | 0 |  |  | SFNM | BUF | M/S | AEOI | Mode
  • SFNM: 1 = Special Fully Nested Mode, 0 = Fully Nested Mode. Pas de spécial pour moi, merci.
  • M/S: 1 = Maître, 0 = eSclave
  • AEOI: 1 = Auto End of Interrupt (fin automatique d'interruption), 0 = Normal.
  • Mode: 0 = 8085, 1 = 8086

Dans notre cas, ce sera donc 5 pour le maître et 1 pour l'esclave.

Sélectionnez

                mov al, 0x05	; initialisation de ICW4
                out 0x21, al
                mov al, 0x01	; esclave
                out 0xA1, al

IX.4.f. OCW1

Notons que c'est l'ordre des opérations qui permet au PIC de comprendre de quoi on cause. Une fois l'initiation faite, on peut envoyer des OCW, Operational Command Word, mot de commande opérationnelle. OCW1 est sur la seconde adresse, et détermine quelles sont les interruptions masquées. Pour utiliser toutes les interruptions :

Sélectionnez

                xor al, al	; masquage des interruptions
                out 0x21, al
                out 0xA1, al

IX.4.g. Fin de traitement d'interruption

Lorsqu'un PIC déclenche une interruption, il faut lui dire qu'elle s'est bien passée. Il y a 8 interruptions par PIC, que l'on va traiter ainsi :

Sélectionnez

            interruptionPICParDefaut:
                mov al,0x20	; EOI (End Of Interrupt)
                out 0x20,al	; qu'on envoie au PIC
                iret

Avant de quitter l'interruption, si elle a été déclenchée par un PIC, on écrit sur le port 0x20 l'octet 0x20, qui signifie "fin de l'interruption" et qui s'appelle EOI.

Une fois tout ceci fait, on a des interruptions que l'on peut utiliser. Malheureusement, il n'y a rien dedans.


  • X. Remerciements

Merci à l'équipe de developpez.com, et en particulier à ClaudeLELOUP pour sa relecture soigneuse et à E-Sh4rk pour sa maîtrise des puissances de 2.

Les sources présentées sur cette page sont libres de droits et vous pouvez les utiliser à votre convenance. Par contre, la page de présentation constitue une œuvre intellectuelle protégée par les droits d'auteur. Copyright © 2011 Etienne Sauvage. Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.

Responsable bénévole de la rubrique Assembleur : Alcatîz -


Accueil

Accueil CHM

Normal, dit comme ça,
vous ne risquez pas de comprendre.